Skip to content

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) - #4871

Merged
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector
Jul 31, 2026
Merged

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)#4871
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector

Conversation

@imorland

@imorlandimorland commented Jul 31, 2026

Copy link
Copy Markdown
Member

Changes proposed in this pull request

Every request sent through Flarum\Testing\integration\TestCase::send() is now inspected for N+1 query patterns, and the test fails when one is found.

The motivation is concrete: while profiling discussion-view performance this week, one extension turned out to be issuing a query per post on every page of every discussion, and another declared default includes that were never eager-loaded. Both were found by hand-profiling a live forum — no test noticed, because no test could. Extension authors should get that feedback while writing the feature.

And it immediately proved the point. On this PR's first CI run the detector failed two tests in flarum/flags — a bundled extension nobody had profiled, whose suite was green. GET /api/flags declares post.discussion and post.user as default includes but never eager-loaded what those nested resources need: core's DiscussionResource eager-loads the actor's discussion state on its own endpoints, and that doesn't carry over when a discussion is included by another resource. So every flag on the moderation page read discussion_user on its own, and the flag authors' groups were re-fetched per flag. That fix is included here (FlagResource), because a detector that lands with a known finding allow-listed on day one teaches the wrong lesson.

How it detects. An N+1 is one query shape executed once per record. RepeatedQueryDetector groups a request's query log by normalised SQL (IN lists collapsed so batched loads of differing sizes count as one shape, numeric and quoted literals replaced, transaction/savepoint noise skipped) and fails when a shape repeats past a threshold (default 5).

Two tiers, decided by whether the work grows with the data. Bindings are counted rather than folded into the shape, and that count is what separates a defect from mere waste:

SignatureMeaningResult
distinct bindings ≈ executions (10x, 10 distinct)one query per record — add rows, add queriesfails
a few values repeated (8x, 1 distinct)wasteful but bounded; five queries for two users stays five at two millionwarns

The warning is a E_USER_WARNING, which PHPUnit attributes to the triggering test — the author sees it without the build going red.

Making the warnings actually visible. As first written the warning tier was invisible three times over: the trigger_error call was @-silenced so PHPUnit never saw it, only one of eighteen phpunit configs in this repo enabled displayDetailsOnTestsThatTriggerWarnings, and a developer who relies on CI would have had to read the middle of a job log regardless. Fixed here: the @ is gone, every integration config displays warning details, and the detector appends findings to FLARUM_REPEATED_QUERY_LOG when set. The reusable backend workflow points that at a temp file and turns it into GitHub annotations (errors for N+1s, warnings for non-scaling repetition) plus a table in the run summary — so findings land on the pull request itself, where the CI-only audience is looking.

This calibration came from running the detector across every bundled extension: beyond the flags N+1 it produced 22 further findings, and all of them were the second kind (5–10 executions, 1–4 distinct values), mostly on post-save paths where a formatter resolves each mention. Failing on those would have meant rewriting formatter and write-path code for no scaling benefit — the threshold was what was wrong, not the extensions. mentions (19 findings) and subscriptions (1) now pass with warnings; the flags N+1 still fails.

On by default, with graduated escapes:

EscapeScopeWhen
allowedRepeatedQueries()one query shapePreferred — rest of the request stays covered
detectsRepeatedQueries()one test caseA test that legitimately can't satisfy it
FLARUM_DETECT_REPEATED_QUERIES=0whole runBisecting an unrelated failure

Verification

  • 11 unit tests on the detector: the real N+1 shape, batched in (…) loads of differing sizes (must not flag), sub-threshold loops, same-bindings repetition, transaction noise, inlined vs bound literals, ordering, and the message format.
  • Against a real N+1: reintroducing the per-post loading in fof/moderator-warnings fails with 10x (10 distinct bindings): select * from warningswherewarnings.post_id = ?. With the fix restored, that extension's whole suite passes with detection on.
  • Against core: tests/integration/api gives identical results with detection on and off — 353 tests, the same pre-existing failures, zero findings.
  • flags: 16 tests green with detection on, after the FlagResource fix (was 2 failures).
  • Bundled extensions: flags, mentions, subscriptions and approval all pass with detection on. tags (5) and likes (1) have pre-existing failures unrelated to queries — identical with FLARUM_DETECT_REPEATED_QUERIES=0.

Reviewers should focus on

  • The threshold (5) and whether the normalisation is too aggressive or not aggressive enough for query shapes I haven't seen.
  • The distinctBindings >= count - 1 rule that decides fail vs warn (one duplicate is tolerated, since batch loaders often re-read a single value while still doing per-record work), and whether that boundary holds for shapes I haven't seen.
  • Implementation note worth knowing: processIsolation="true" makes PHPUnit parse a subprocess's entire output as its result protocol, so printing findings — STDOUT, STDERR, /dev/tty, shutdown functions — all surface as test errors. Failing the assertion is the only channel PHPUnit sanctions here.

Developer documentation: flarum/docs#568.

Confirmed

  • Backend changes: tests are green (run composer test).

Every request sent through the integration TestCase is now inspected for
N+1 query patterns, and the test fails when it finds one. Extension
authors get the feedback while writing the feature rather than when a
forum grows: this session alone, one extension was issuing a query per
post on every page of every discussion, found only by hand-profiling a
live forum.
An N+1 is one query shape executed once per record. The detector groups
a request's query log by normalised SQL — IN lists collapsed, literals
replaced — and fails when a shape repeats past a threshold. Bindings are
counted separately rather than folded into the shape: the same SQL run
for four different users is not the same defect as one query per row,
and conflating them produces false positives (it fooled me on one
extension before this distinction existed).
On by default. A single legitimate shape can be exempted with
allowedRepeatedQueries(); a test case can override
detectsRepeatedQueries(); FLARUM_DETECT_REPEATED_QUERIES=0 disables it
for a whole run.
Verified against core's api suite: identical results with detection on
and off (353 tests, same pre-existing failures, no findings), and
against a real N+1 reintroduced in an extension, where it fails with
'10x (10 distinct bindings)' naming the offending query.
@imorland
imorland requested a review from a team as a code ownerJuly 31, 2026 16:47
The flags index declares post.discussion and post.user as default
includes, but never eager loaded what those nested resources need. Core's
DiscussionResource eager loads the actor's discussion state on its own
endpoints; that doesn't carry over when a discussion is included by
another resource. So every flag on the moderation page read
discussion_user on its own, and the flag authors' groups were re-fetched
per flag.
Caught by the N+1 detection added in this branch, on its first CI run.
@imorlandimorland changed the title [2.x] feat: fail integration tests that run N+1 queries[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)Jul 31, 2026
Running the detector across the bundled extensions turned up 22 findings
beyond the flags N+1, and they were all the same shape: a query repeated
5-10 times for only 1-4 distinct values. That is not an N+1 — five
queries for two users stays five queries whether the forum has two users
or two million. Failing on it would have meant rewriting formatter and
write-path code for no scaling benefit, so the threshold was the thing
that was wrong.
The two cases are now distinguished by the data already being collected.
Roughly as many distinct bindings as executions means one query per
record: that fails, because the work grows with the forum. A handful of
values repeated is wasteful but bounded: that raises a PHP warning, which
PHPUnit attributes to the test without failing the run.
mentions (19 findings) and subscriptions (1) now pass; the flags N+1
still fails when its fix is reverted.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
The warning tier was invisible in three separate ways. The trigger_error
call was prefixed with @, so PHPUnit never saw it at all — nothing
appeared even with --display-warnings. Only one of eighteen phpunit
configs in this repo set displayDetailsOnTestsThatTriggerWarnings, so
even a working warning printed no detail. And a developer who relies on
CI rather than local runs would have to read the middle of a job log to
find either.
So: the @ is gone, every integration config displays warning details, and
the detector appends findings to FLARUM_REPEATED_QUERY_LOG when it is
set. The reusable backend workflow points that at a temp file and turns
it into GitHub annotations — errors for N+1s, warnings for non-scaling
repetition — plus a table in the run summary. Annotations attach to the
pull request, which is where the audience that most needs them is
looking.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Without displayDetailsOnTestsThatTriggerWarnings PHPUnit prints only a
count — 'Warnings: 11' — which is visible but not actionable. The first
warning of a run now carries the pointer to --display-warnings and the
config setting.
Once per run, not per finding: tests run with processIsolation, so each
test is a separate process and a static flag cannot track 'first'. A
marker file keyed to the project and the hour serves as the shared
signal.
@imorland
imorland merged commit a9b5d25 into 2.xJul 31, 2026
25 checks passed
@imorland
imorland deleted the im/testing-n1-detector branch July 31, 2026 21:00
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
* Document N+1 query detection in integration tests
Companion to flarum/framework#4871, which fails integration tests whose
requests run N+1 queries. Explains how to read a failure, what the
binding count means, and how to exempt a legitimately repeated query
shape.
* Distinguish N+1 failures from non-scaling query warnings
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
* Document where query findings surface
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@imorland@StyleCIBot
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) by imorland · Pull Request #4871 · flarum/framework · GitHub
Skip to content

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) - #4871

Merged
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector
Jul 31, 2026
Merged

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)#4871
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector

Conversation

@imorland

@imorlandimorland commented Jul 31, 2026

Copy link
Copy Markdown
Member

Changes proposed in this pull request

Every request sent through Flarum\Testing\integration\TestCase::send() is now inspected for N+1 query patterns, and the test fails when one is found.

The motivation is concrete: while profiling discussion-view performance this week, one extension turned out to be issuing a query per post on every page of every discussion, and another declared default includes that were never eager-loaded. Both were found by hand-profiling a live forum — no test noticed, because no test could. Extension authors should get that feedback while writing the feature.

And it immediately proved the point. On this PR's first CI run the detector failed two tests in flarum/flags — a bundled extension nobody had profiled, whose suite was green. GET /api/flags declares post.discussion and post.user as default includes but never eager-loaded what those nested resources need: core's DiscussionResource eager-loads the actor's discussion state on its own endpoints, and that doesn't carry over when a discussion is included by another resource. So every flag on the moderation page read discussion_user on its own, and the flag authors' groups were re-fetched per flag. That fix is included here (FlagResource), because a detector that lands with a known finding allow-listed on day one teaches the wrong lesson.

How it detects. An N+1 is one query shape executed once per record. RepeatedQueryDetector groups a request's query log by normalised SQL (IN lists collapsed so batched loads of differing sizes count as one shape, numeric and quoted literals replaced, transaction/savepoint noise skipped) and fails when a shape repeats past a threshold (default 5).

Two tiers, decided by whether the work grows with the data. Bindings are counted rather than folded into the shape, and that count is what separates a defect from mere waste:

SignatureMeaningResult
distinct bindings ≈ executions (10x, 10 distinct)one query per record — add rows, add queriesfails
a few values repeated (8x, 1 distinct)wasteful but bounded; five queries for two users stays five at two millionwarns

The warning is a E_USER_WARNING, which PHPUnit attributes to the triggering test — the author sees it without the build going red.

Making the warnings actually visible. As first written the warning tier was invisible three times over: the trigger_error call was @-silenced so PHPUnit never saw it, only one of eighteen phpunit configs in this repo enabled displayDetailsOnTestsThatTriggerWarnings, and a developer who relies on CI would have had to read the middle of a job log regardless. Fixed here: the @ is gone, every integration config displays warning details, and the detector appends findings to FLARUM_REPEATED_QUERY_LOG when set. The reusable backend workflow points that at a temp file and turns it into GitHub annotations (errors for N+1s, warnings for non-scaling repetition) plus a table in the run summary — so findings land on the pull request itself, where the CI-only audience is looking.

This calibration came from running the detector across every bundled extension: beyond the flags N+1 it produced 22 further findings, and all of them were the second kind (5–10 executions, 1–4 distinct values), mostly on post-save paths where a formatter resolves each mention. Failing on those would have meant rewriting formatter and write-path code for no scaling benefit — the threshold was what was wrong, not the extensions. mentions (19 findings) and subscriptions (1) now pass with warnings; the flags N+1 still fails.

On by default, with graduated escapes:

EscapeScopeWhen
allowedRepeatedQueries()one query shapePreferred — rest of the request stays covered
detectsRepeatedQueries()one test caseA test that legitimately can't satisfy it
FLARUM_DETECT_REPEATED_QUERIES=0whole runBisecting an unrelated failure

Verification

  • 11 unit tests on the detector: the real N+1 shape, batched in (…) loads of differing sizes (must not flag), sub-threshold loops, same-bindings repetition, transaction noise, inlined vs bound literals, ordering, and the message format.
  • Against a real N+1: reintroducing the per-post loading in fof/moderator-warnings fails with 10x (10 distinct bindings): select * from warningswherewarnings.post_id = ?. With the fix restored, that extension's whole suite passes with detection on.
  • Against core: tests/integration/api gives identical results with detection on and off — 353 tests, the same pre-existing failures, zero findings.
  • flags: 16 tests green with detection on, after the FlagResource fix (was 2 failures).
  • Bundled extensions: flags, mentions, subscriptions and approval all pass with detection on. tags (5) and likes (1) have pre-existing failures unrelated to queries — identical with FLARUM_DETECT_REPEATED_QUERIES=0.

Reviewers should focus on

  • The threshold (5) and whether the normalisation is too aggressive or not aggressive enough for query shapes I haven't seen.
  • The distinctBindings >= count - 1 rule that decides fail vs warn (one duplicate is tolerated, since batch loaders often re-read a single value while still doing per-record work), and whether that boundary holds for shapes I haven't seen.
  • Implementation note worth knowing: processIsolation="true" makes PHPUnit parse a subprocess's entire output as its result protocol, so printing findings — STDOUT, STDERR, /dev/tty, shutdown functions — all surface as test errors. Failing the assertion is the only channel PHPUnit sanctions here.

Developer documentation: flarum/docs#568.

Confirmed

  • Backend changes: tests are green (run composer test).

Every request sent through the integration TestCase is now inspected for
N+1 query patterns, and the test fails when it finds one. Extension
authors get the feedback while writing the feature rather than when a
forum grows: this session alone, one extension was issuing a query per
post on every page of every discussion, found only by hand-profiling a
live forum.
An N+1 is one query shape executed once per record. The detector groups
a request's query log by normalised SQL — IN lists collapsed, literals
replaced — and fails when a shape repeats past a threshold. Bindings are
counted separately rather than folded into the shape: the same SQL run
for four different users is not the same defect as one query per row,
and conflating them produces false positives (it fooled me on one
extension before this distinction existed).
On by default. A single legitimate shape can be exempted with
allowedRepeatedQueries(); a test case can override
detectsRepeatedQueries(); FLARUM_DETECT_REPEATED_QUERIES=0 disables it
for a whole run.
Verified against core's api suite: identical results with detection on
and off (353 tests, same pre-existing failures, no findings), and
against a real N+1 reintroduced in an extension, where it fails with
'10x (10 distinct bindings)' naming the offending query.
@imorland
imorland requested a review from a team as a code ownerJuly 31, 2026 16:47
The flags index declares post.discussion and post.user as default
includes, but never eager loaded what those nested resources need. Core's
DiscussionResource eager loads the actor's discussion state on its own
endpoints; that doesn't carry over when a discussion is included by
another resource. So every flag on the moderation page read
discussion_user on its own, and the flag authors' groups were re-fetched
per flag.
Caught by the N+1 detection added in this branch, on its first CI run.
@imorlandimorland changed the title [2.x] feat: fail integration tests that run N+1 queries[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)Jul 31, 2026
Running the detector across the bundled extensions turned up 22 findings
beyond the flags N+1, and they were all the same shape: a query repeated
5-10 times for only 1-4 distinct values. That is not an N+1 — five
queries for two users stays five queries whether the forum has two users
or two million. Failing on it would have meant rewriting formatter and
write-path code for no scaling benefit, so the threshold was the thing
that was wrong.
The two cases are now distinguished by the data already being collected.
Roughly as many distinct bindings as executions means one query per
record: that fails, because the work grows with the forum. A handful of
values repeated is wasteful but bounded: that raises a PHP warning, which
PHPUnit attributes to the test without failing the run.
mentions (19 findings) and subscriptions (1) now pass; the flags N+1
still fails when its fix is reverted.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
The warning tier was invisible in three separate ways. The trigger_error
call was prefixed with @, so PHPUnit never saw it at all — nothing
appeared even with --display-warnings. Only one of eighteen phpunit
configs in this repo set displayDetailsOnTestsThatTriggerWarnings, so
even a working warning printed no detail. And a developer who relies on
CI rather than local runs would have to read the middle of a job log to
find either.
So: the @ is gone, every integration config displays warning details, and
the detector appends findings to FLARUM_REPEATED_QUERY_LOG when it is
set. The reusable backend workflow points that at a temp file and turns
it into GitHub annotations — errors for N+1s, warnings for non-scaling
repetition — plus a table in the run summary. Annotations attach to the
pull request, which is where the audience that most needs them is
looking.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Without displayDetailsOnTestsThatTriggerWarnings PHPUnit prints only a
count — 'Warnings: 11' — which is visible but not actionable. The first
warning of a run now carries the pointer to --display-warnings and the
config setting.
Once per run, not per finding: tests run with processIsolation, so each
test is a separate process and a static flag cannot track 'first'. A
marker file keyed to the project and the hour serves as the shared
signal.
@imorland
imorland merged commit a9b5d25 into 2.xJul 31, 2026
25 checks passed
@imorland
imorland deleted the im/testing-n1-detector branch July 31, 2026 21:00
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
* Document N+1 query detection in integration tests
Companion to flarum/framework#4871, which fails integration tests whose
requests run N+1 queries. Explains how to read a failure, what the
binding count means, and how to exempt a legitimately repeated query
shape.
* Distinguish N+1 failures from non-scaling query warnings
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
* Document where query findings surface
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@imorland@StyleCIBot
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) by imorland · Pull Request #4871 · flarum/framework · GitHub
Skip to content

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) - #4871

Merged
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector
Jul 31, 2026
Merged

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)#4871
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector

Conversation

@imorland

@imorlandimorland commented Jul 31, 2026

Copy link
Copy Markdown
Member

Changes proposed in this pull request

Every request sent through Flarum\Testing\integration\TestCase::send() is now inspected for N+1 query patterns, and the test fails when one is found.

The motivation is concrete: while profiling discussion-view performance this week, one extension turned out to be issuing a query per post on every page of every discussion, and another declared default includes that were never eager-loaded. Both were found by hand-profiling a live forum — no test noticed, because no test could. Extension authors should get that feedback while writing the feature.

And it immediately proved the point. On this PR's first CI run the detector failed two tests in flarum/flags — a bundled extension nobody had profiled, whose suite was green. GET /api/flags declares post.discussion and post.user as default includes but never eager-loaded what those nested resources need: core's DiscussionResource eager-loads the actor's discussion state on its own endpoints, and that doesn't carry over when a discussion is included by another resource. So every flag on the moderation page read discussion_user on its own, and the flag authors' groups were re-fetched per flag. That fix is included here (FlagResource), because a detector that lands with a known finding allow-listed on day one teaches the wrong lesson.

How it detects. An N+1 is one query shape executed once per record. RepeatedQueryDetector groups a request's query log by normalised SQL (IN lists collapsed so batched loads of differing sizes count as one shape, numeric and quoted literals replaced, transaction/savepoint noise skipped) and fails when a shape repeats past a threshold (default 5).

Two tiers, decided by whether the work grows with the data. Bindings are counted rather than folded into the shape, and that count is what separates a defect from mere waste:

SignatureMeaningResult
distinct bindings ≈ executions (10x, 10 distinct)one query per record — add rows, add queriesfails
a few values repeated (8x, 1 distinct)wasteful but bounded; five queries for two users stays five at two millionwarns

The warning is a E_USER_WARNING, which PHPUnit attributes to the triggering test — the author sees it without the build going red.

Making the warnings actually visible. As first written the warning tier was invisible three times over: the trigger_error call was @-silenced so PHPUnit never saw it, only one of eighteen phpunit configs in this repo enabled displayDetailsOnTestsThatTriggerWarnings, and a developer who relies on CI would have had to read the middle of a job log regardless. Fixed here: the @ is gone, every integration config displays warning details, and the detector appends findings to FLARUM_REPEATED_QUERY_LOG when set. The reusable backend workflow points that at a temp file and turns it into GitHub annotations (errors for N+1s, warnings for non-scaling repetition) plus a table in the run summary — so findings land on the pull request itself, where the CI-only audience is looking.

This calibration came from running the detector across every bundled extension: beyond the flags N+1 it produced 22 further findings, and all of them were the second kind (5–10 executions, 1–4 distinct values), mostly on post-save paths where a formatter resolves each mention. Failing on those would have meant rewriting formatter and write-path code for no scaling benefit — the threshold was what was wrong, not the extensions. mentions (19 findings) and subscriptions (1) now pass with warnings; the flags N+1 still fails.

On by default, with graduated escapes:

EscapeScopeWhen
allowedRepeatedQueries()one query shapePreferred — rest of the request stays covered
detectsRepeatedQueries()one test caseA test that legitimately can't satisfy it
FLARUM_DETECT_REPEATED_QUERIES=0whole runBisecting an unrelated failure

Verification

  • 11 unit tests on the detector: the real N+1 shape, batched in (…) loads of differing sizes (must not flag), sub-threshold loops, same-bindings repetition, transaction noise, inlined vs bound literals, ordering, and the message format.
  • Against a real N+1: reintroducing the per-post loading in fof/moderator-warnings fails with 10x (10 distinct bindings): select * from warningswherewarnings.post_id = ?. With the fix restored, that extension's whole suite passes with detection on.
  • Against core: tests/integration/api gives identical results with detection on and off — 353 tests, the same pre-existing failures, zero findings.
  • flags: 16 tests green with detection on, after the FlagResource fix (was 2 failures).
  • Bundled extensions: flags, mentions, subscriptions and approval all pass with detection on. tags (5) and likes (1) have pre-existing failures unrelated to queries — identical with FLARUM_DETECT_REPEATED_QUERIES=0.

Reviewers should focus on

  • The threshold (5) and whether the normalisation is too aggressive or not aggressive enough for query shapes I haven't seen.
  • The distinctBindings >= count - 1 rule that decides fail vs warn (one duplicate is tolerated, since batch loaders often re-read a single value while still doing per-record work), and whether that boundary holds for shapes I haven't seen.
  • Implementation note worth knowing: processIsolation="true" makes PHPUnit parse a subprocess's entire output as its result protocol, so printing findings — STDOUT, STDERR, /dev/tty, shutdown functions — all surface as test errors. Failing the assertion is the only channel PHPUnit sanctions here.

Developer documentation: flarum/docs#568.

Confirmed

  • Backend changes: tests are green (run composer test).

Every request sent through the integration TestCase is now inspected for
N+1 query patterns, and the test fails when it finds one. Extension
authors get the feedback while writing the feature rather than when a
forum grows: this session alone, one extension was issuing a query per
post on every page of every discussion, found only by hand-profiling a
live forum.
An N+1 is one query shape executed once per record. The detector groups
a request's query log by normalised SQL — IN lists collapsed, literals
replaced — and fails when a shape repeats past a threshold. Bindings are
counted separately rather than folded into the shape: the same SQL run
for four different users is not the same defect as one query per row,
and conflating them produces false positives (it fooled me on one
extension before this distinction existed).
On by default. A single legitimate shape can be exempted with
allowedRepeatedQueries(); a test case can override
detectsRepeatedQueries(); FLARUM_DETECT_REPEATED_QUERIES=0 disables it
for a whole run.
Verified against core's api suite: identical results with detection on
and off (353 tests, same pre-existing failures, no findings), and
against a real N+1 reintroduced in an extension, where it fails with
'10x (10 distinct bindings)' naming the offending query.
@imorland
imorland requested a review from a team as a code ownerJuly 31, 2026 16:47
The flags index declares post.discussion and post.user as default
includes, but never eager loaded what those nested resources need. Core's
DiscussionResource eager loads the actor's discussion state on its own
endpoints; that doesn't carry over when a discussion is included by
another resource. So every flag on the moderation page read
discussion_user on its own, and the flag authors' groups were re-fetched
per flag.
Caught by the N+1 detection added in this branch, on its first CI run.
@imorlandimorland changed the title [2.x] feat: fail integration tests that run N+1 queries[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)Jul 31, 2026
Running the detector across the bundled extensions turned up 22 findings
beyond the flags N+1, and they were all the same shape: a query repeated
5-10 times for only 1-4 distinct values. That is not an N+1 — five
queries for two users stays five queries whether the forum has two users
or two million. Failing on it would have meant rewriting formatter and
write-path code for no scaling benefit, so the threshold was the thing
that was wrong.
The two cases are now distinguished by the data already being collected.
Roughly as many distinct bindings as executions means one query per
record: that fails, because the work grows with the forum. A handful of
values repeated is wasteful but bounded: that raises a PHP warning, which
PHPUnit attributes to the test without failing the run.
mentions (19 findings) and subscriptions (1) now pass; the flags N+1
still fails when its fix is reverted.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
The warning tier was invisible in three separate ways. The trigger_error
call was prefixed with @, so PHPUnit never saw it at all — nothing
appeared even with --display-warnings. Only one of eighteen phpunit
configs in this repo set displayDetailsOnTestsThatTriggerWarnings, so
even a working warning printed no detail. And a developer who relies on
CI rather than local runs would have to read the middle of a job log to
find either.
So: the @ is gone, every integration config displays warning details, and
the detector appends findings to FLARUM_REPEATED_QUERY_LOG when it is
set. The reusable backend workflow points that at a temp file and turns
it into GitHub annotations — errors for N+1s, warnings for non-scaling
repetition — plus a table in the run summary. Annotations attach to the
pull request, which is where the audience that most needs them is
looking.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Without displayDetailsOnTestsThatTriggerWarnings PHPUnit prints only a
count — 'Warnings: 11' — which is visible but not actionable. The first
warning of a run now carries the pointer to --display-warnings and the
config setting.
Once per run, not per finding: tests run with processIsolation, so each
test is a separate process and a static flag cannot track 'first'. A
marker file keyed to the project and the hour serves as the shared
signal.
@imorland
imorland merged commit a9b5d25 into 2.xJul 31, 2026
25 checks passed
@imorland
imorland deleted the im/testing-n1-detector branch July 31, 2026 21:00
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
* Document N+1 query detection in integration tests
Companion to flarum/framework#4871, which fails integration tests whose
requests run N+1 queries. Explains how to read a failure, what the
binding count means, and how to exempt a legitimately repeated query
shape.
* Distinguish N+1 failures from non-scaling query warnings
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
* Document where query findings surface
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@imorland@StyleCIBot
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) by imorland · Pull Request #4871 · flarum/framework · GitHub
Skip to content

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) - #4871

Merged
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector
Jul 31, 2026
Merged

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)#4871
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector

Conversation

@imorland

@imorlandimorland commented Jul 31, 2026

Copy link
Copy Markdown
Member

Changes proposed in this pull request

Every request sent through Flarum\Testing\integration\TestCase::send() is now inspected for N+1 query patterns, and the test fails when one is found.

The motivation is concrete: while profiling discussion-view performance this week, one extension turned out to be issuing a query per post on every page of every discussion, and another declared default includes that were never eager-loaded. Both were found by hand-profiling a live forum — no test noticed, because no test could. Extension authors should get that feedback while writing the feature.

And it immediately proved the point. On this PR's first CI run the detector failed two tests in flarum/flags — a bundled extension nobody had profiled, whose suite was green. GET /api/flags declares post.discussion and post.user as default includes but never eager-loaded what those nested resources need: core's DiscussionResource eager-loads the actor's discussion state on its own endpoints, and that doesn't carry over when a discussion is included by another resource. So every flag on the moderation page read discussion_user on its own, and the flag authors' groups were re-fetched per flag. That fix is included here (FlagResource), because a detector that lands with a known finding allow-listed on day one teaches the wrong lesson.

How it detects. An N+1 is one query shape executed once per record. RepeatedQueryDetector groups a request's query log by normalised SQL (IN lists collapsed so batched loads of differing sizes count as one shape, numeric and quoted literals replaced, transaction/savepoint noise skipped) and fails when a shape repeats past a threshold (default 5).

Two tiers, decided by whether the work grows with the data. Bindings are counted rather than folded into the shape, and that count is what separates a defect from mere waste:

SignatureMeaningResult
distinct bindings ≈ executions (10x, 10 distinct)one query per record — add rows, add queriesfails
a few values repeated (8x, 1 distinct)wasteful but bounded; five queries for two users stays five at two millionwarns

The warning is a E_USER_WARNING, which PHPUnit attributes to the triggering test — the author sees it without the build going red.

Making the warnings actually visible. As first written the warning tier was invisible three times over: the trigger_error call was @-silenced so PHPUnit never saw it, only one of eighteen phpunit configs in this repo enabled displayDetailsOnTestsThatTriggerWarnings, and a developer who relies on CI would have had to read the middle of a job log regardless. Fixed here: the @ is gone, every integration config displays warning details, and the detector appends findings to FLARUM_REPEATED_QUERY_LOG when set. The reusable backend workflow points that at a temp file and turns it into GitHub annotations (errors for N+1s, warnings for non-scaling repetition) plus a table in the run summary — so findings land on the pull request itself, where the CI-only audience is looking.

This calibration came from running the detector across every bundled extension: beyond the flags N+1 it produced 22 further findings, and all of them were the second kind (5–10 executions, 1–4 distinct values), mostly on post-save paths where a formatter resolves each mention. Failing on those would have meant rewriting formatter and write-path code for no scaling benefit — the threshold was what was wrong, not the extensions. mentions (19 findings) and subscriptions (1) now pass with warnings; the flags N+1 still fails.

On by default, with graduated escapes:

EscapeScopeWhen
allowedRepeatedQueries()one query shapePreferred — rest of the request stays covered
detectsRepeatedQueries()one test caseA test that legitimately can't satisfy it
FLARUM_DETECT_REPEATED_QUERIES=0whole runBisecting an unrelated failure

Verification

  • 11 unit tests on the detector: the real N+1 shape, batched in (…) loads of differing sizes (must not flag), sub-threshold loops, same-bindings repetition, transaction noise, inlined vs bound literals, ordering, and the message format.
  • Against a real N+1: reintroducing the per-post loading in fof/moderator-warnings fails with 10x (10 distinct bindings): select * from warningswherewarnings.post_id = ?. With the fix restored, that extension's whole suite passes with detection on.
  • Against core: tests/integration/api gives identical results with detection on and off — 353 tests, the same pre-existing failures, zero findings.
  • flags: 16 tests green with detection on, after the FlagResource fix (was 2 failures).
  • Bundled extensions: flags, mentions, subscriptions and approval all pass with detection on. tags (5) and likes (1) have pre-existing failures unrelated to queries — identical with FLARUM_DETECT_REPEATED_QUERIES=0.

Reviewers should focus on

  • The threshold (5) and whether the normalisation is too aggressive or not aggressive enough for query shapes I haven't seen.
  • The distinctBindings >= count - 1 rule that decides fail vs warn (one duplicate is tolerated, since batch loaders often re-read a single value while still doing per-record work), and whether that boundary holds for shapes I haven't seen.
  • Implementation note worth knowing: processIsolation="true" makes PHPUnit parse a subprocess's entire output as its result protocol, so printing findings — STDOUT, STDERR, /dev/tty, shutdown functions — all surface as test errors. Failing the assertion is the only channel PHPUnit sanctions here.

Developer documentation: flarum/docs#568.

Confirmed

  • Backend changes: tests are green (run composer test).

Every request sent through the integration TestCase is now inspected for
N+1 query patterns, and the test fails when it finds one. Extension
authors get the feedback while writing the feature rather than when a
forum grows: this session alone, one extension was issuing a query per
post on every page of every discussion, found only by hand-profiling a
live forum.
An N+1 is one query shape executed once per record. The detector groups
a request's query log by normalised SQL — IN lists collapsed, literals
replaced — and fails when a shape repeats past a threshold. Bindings are
counted separately rather than folded into the shape: the same SQL run
for four different users is not the same defect as one query per row,
and conflating them produces false positives (it fooled me on one
extension before this distinction existed).
On by default. A single legitimate shape can be exempted with
allowedRepeatedQueries(); a test case can override
detectsRepeatedQueries(); FLARUM_DETECT_REPEATED_QUERIES=0 disables it
for a whole run.
Verified against core's api suite: identical results with detection on
and off (353 tests, same pre-existing failures, no findings), and
against a real N+1 reintroduced in an extension, where it fails with
'10x (10 distinct bindings)' naming the offending query.
@imorland
imorland requested a review from a team as a code ownerJuly 31, 2026 16:47
The flags index declares post.discussion and post.user as default
includes, but never eager loaded what those nested resources need. Core's
DiscussionResource eager loads the actor's discussion state on its own
endpoints; that doesn't carry over when a discussion is included by
another resource. So every flag on the moderation page read
discussion_user on its own, and the flag authors' groups were re-fetched
per flag.
Caught by the N+1 detection added in this branch, on its first CI run.
@imorlandimorland changed the title [2.x] feat: fail integration tests that run N+1 queries[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)Jul 31, 2026
Running the detector across the bundled extensions turned up 22 findings
beyond the flags N+1, and they were all the same shape: a query repeated
5-10 times for only 1-4 distinct values. That is not an N+1 — five
queries for two users stays five queries whether the forum has two users
or two million. Failing on it would have meant rewriting formatter and
write-path code for no scaling benefit, so the threshold was the thing
that was wrong.
The two cases are now distinguished by the data already being collected.
Roughly as many distinct bindings as executions means one query per
record: that fails, because the work grows with the forum. A handful of
values repeated is wasteful but bounded: that raises a PHP warning, which
PHPUnit attributes to the test without failing the run.
mentions (19 findings) and subscriptions (1) now pass; the flags N+1
still fails when its fix is reverted.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
The warning tier was invisible in three separate ways. The trigger_error
call was prefixed with @, so PHPUnit never saw it at all — nothing
appeared even with --display-warnings. Only one of eighteen phpunit
configs in this repo set displayDetailsOnTestsThatTriggerWarnings, so
even a working warning printed no detail. And a developer who relies on
CI rather than local runs would have to read the middle of a job log to
find either.
So: the @ is gone, every integration config displays warning details, and
the detector appends findings to FLARUM_REPEATED_QUERY_LOG when it is
set. The reusable backend workflow points that at a temp file and turns
it into GitHub annotations — errors for N+1s, warnings for non-scaling
repetition — plus a table in the run summary. Annotations attach to the
pull request, which is where the audience that most needs them is
looking.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Without displayDetailsOnTestsThatTriggerWarnings PHPUnit prints only a
count — 'Warnings: 11' — which is visible but not actionable. The first
warning of a run now carries the pointer to --display-warnings and the
config setting.
Once per run, not per finding: tests run with processIsolation, so each
test is a separate process and a static flag cannot track 'first'. A
marker file keyed to the project and the hour serves as the shared
signal.
@imorland
imorland merged commit a9b5d25 into 2.xJul 31, 2026
25 checks passed
@imorland
imorland deleted the im/testing-n1-detector branch July 31, 2026 21:00
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
* Document N+1 query detection in integration tests
Companion to flarum/framework#4871, which fails integration tests whose
requests run N+1 queries. Explains how to read a failure, what the
binding count means, and how to exempt a legitimately repeated query
shape.
* Distinguish N+1 failures from non-scaling query warnings
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
* Document where query findings surface
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@imorland@StyleCIBot
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) by imorland · Pull Request #4871 · flarum/framework · GitHub
Skip to content

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) - #4871

Merged
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector
Jul 31, 2026
Merged

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)#4871
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector

Conversation

@imorland

@imorlandimorland commented Jul 31, 2026

Copy link
Copy Markdown
Member

Changes proposed in this pull request

Every request sent through Flarum\Testing\integration\TestCase::send() is now inspected for N+1 query patterns, and the test fails when one is found.

The motivation is concrete: while profiling discussion-view performance this week, one extension turned out to be issuing a query per post on every page of every discussion, and another declared default includes that were never eager-loaded. Both were found by hand-profiling a live forum — no test noticed, because no test could. Extension authors should get that feedback while writing the feature.

And it immediately proved the point. On this PR's first CI run the detector failed two tests in flarum/flags — a bundled extension nobody had profiled, whose suite was green. GET /api/flags declares post.discussion and post.user as default includes but never eager-loaded what those nested resources need: core's DiscussionResource eager-loads the actor's discussion state on its own endpoints, and that doesn't carry over when a discussion is included by another resource. So every flag on the moderation page read discussion_user on its own, and the flag authors' groups were re-fetched per flag. That fix is included here (FlagResource), because a detector that lands with a known finding allow-listed on day one teaches the wrong lesson.

How it detects. An N+1 is one query shape executed once per record. RepeatedQueryDetector groups a request's query log by normalised SQL (IN lists collapsed so batched loads of differing sizes count as one shape, numeric and quoted literals replaced, transaction/savepoint noise skipped) and fails when a shape repeats past a threshold (default 5).

Two tiers, decided by whether the work grows with the data. Bindings are counted rather than folded into the shape, and that count is what separates a defect from mere waste:

SignatureMeaningResult
distinct bindings ≈ executions (10x, 10 distinct)one query per record — add rows, add queriesfails
a few values repeated (8x, 1 distinct)wasteful but bounded; five queries for two users stays five at two millionwarns

The warning is a E_USER_WARNING, which PHPUnit attributes to the triggering test — the author sees it without the build going red.

Making the warnings actually visible. As first written the warning tier was invisible three times over: the trigger_error call was @-silenced so PHPUnit never saw it, only one of eighteen phpunit configs in this repo enabled displayDetailsOnTestsThatTriggerWarnings, and a developer who relies on CI would have had to read the middle of a job log regardless. Fixed here: the @ is gone, every integration config displays warning details, and the detector appends findings to FLARUM_REPEATED_QUERY_LOG when set. The reusable backend workflow points that at a temp file and turns it into GitHub annotations (errors for N+1s, warnings for non-scaling repetition) plus a table in the run summary — so findings land on the pull request itself, where the CI-only audience is looking.

This calibration came from running the detector across every bundled extension: beyond the flags N+1 it produced 22 further findings, and all of them were the second kind (5–10 executions, 1–4 distinct values), mostly on post-save paths where a formatter resolves each mention. Failing on those would have meant rewriting formatter and write-path code for no scaling benefit — the threshold was what was wrong, not the extensions. mentions (19 findings) and subscriptions (1) now pass with warnings; the flags N+1 still fails.

On by default, with graduated escapes:

EscapeScopeWhen
allowedRepeatedQueries()one query shapePreferred — rest of the request stays covered
detectsRepeatedQueries()one test caseA test that legitimately can't satisfy it
FLARUM_DETECT_REPEATED_QUERIES=0whole runBisecting an unrelated failure

Verification

  • 11 unit tests on the detector: the real N+1 shape, batched in (…) loads of differing sizes (must not flag), sub-threshold loops, same-bindings repetition, transaction noise, inlined vs bound literals, ordering, and the message format.
  • Against a real N+1: reintroducing the per-post loading in fof/moderator-warnings fails with 10x (10 distinct bindings): select * from warningswherewarnings.post_id = ?. With the fix restored, that extension's whole suite passes with detection on.
  • Against core: tests/integration/api gives identical results with detection on and off — 353 tests, the same pre-existing failures, zero findings.
  • flags: 16 tests green with detection on, after the FlagResource fix (was 2 failures).
  • Bundled extensions: flags, mentions, subscriptions and approval all pass with detection on. tags (5) and likes (1) have pre-existing failures unrelated to queries — identical with FLARUM_DETECT_REPEATED_QUERIES=0.

Reviewers should focus on

  • The threshold (5) and whether the normalisation is too aggressive or not aggressive enough for query shapes I haven't seen.
  • The distinctBindings >= count - 1 rule that decides fail vs warn (one duplicate is tolerated, since batch loaders often re-read a single value while still doing per-record work), and whether that boundary holds for shapes I haven't seen.
  • Implementation note worth knowing: processIsolation="true" makes PHPUnit parse a subprocess's entire output as its result protocol, so printing findings — STDOUT, STDERR, /dev/tty, shutdown functions — all surface as test errors. Failing the assertion is the only channel PHPUnit sanctions here.

Developer documentation: flarum/docs#568.

Confirmed

  • Backend changes: tests are green (run composer test).

Every request sent through the integration TestCase is now inspected for
N+1 query patterns, and the test fails when it finds one. Extension
authors get the feedback while writing the feature rather than when a
forum grows: this session alone, one extension was issuing a query per
post on every page of every discussion, found only by hand-profiling a
live forum.
An N+1 is one query shape executed once per record. The detector groups
a request's query log by normalised SQL — IN lists collapsed, literals
replaced — and fails when a shape repeats past a threshold. Bindings are
counted separately rather than folded into the shape: the same SQL run
for four different users is not the same defect as one query per row,
and conflating them produces false positives (it fooled me on one
extension before this distinction existed).
On by default. A single legitimate shape can be exempted with
allowedRepeatedQueries(); a test case can override
detectsRepeatedQueries(); FLARUM_DETECT_REPEATED_QUERIES=0 disables it
for a whole run.
Verified against core's api suite: identical results with detection on
and off (353 tests, same pre-existing failures, no findings), and
against a real N+1 reintroduced in an extension, where it fails with
'10x (10 distinct bindings)' naming the offending query.
@imorland
imorland requested a review from a team as a code ownerJuly 31, 2026 16:47
The flags index declares post.discussion and post.user as default
includes, but never eager loaded what those nested resources need. Core's
DiscussionResource eager loads the actor's discussion state on its own
endpoints; that doesn't carry over when a discussion is included by
another resource. So every flag on the moderation page read
discussion_user on its own, and the flag authors' groups were re-fetched
per flag.
Caught by the N+1 detection added in this branch, on its first CI run.
@imorlandimorland changed the title [2.x] feat: fail integration tests that run N+1 queries[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)Jul 31, 2026
Running the detector across the bundled extensions turned up 22 findings
beyond the flags N+1, and they were all the same shape: a query repeated
5-10 times for only 1-4 distinct values. That is not an N+1 — five
queries for two users stays five queries whether the forum has two users
or two million. Failing on it would have meant rewriting formatter and
write-path code for no scaling benefit, so the threshold was the thing
that was wrong.
The two cases are now distinguished by the data already being collected.
Roughly as many distinct bindings as executions means one query per
record: that fails, because the work grows with the forum. A handful of
values repeated is wasteful but bounded: that raises a PHP warning, which
PHPUnit attributes to the test without failing the run.
mentions (19 findings) and subscriptions (1) now pass; the flags N+1
still fails when its fix is reverted.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
The warning tier was invisible in three separate ways. The trigger_error
call was prefixed with @, so PHPUnit never saw it at all — nothing
appeared even with --display-warnings. Only one of eighteen phpunit
configs in this repo set displayDetailsOnTestsThatTriggerWarnings, so
even a working warning printed no detail. And a developer who relies on
CI rather than local runs would have to read the middle of a job log to
find either.
So: the @ is gone, every integration config displays warning details, and
the detector appends findings to FLARUM_REPEATED_QUERY_LOG when it is
set. The reusable backend workflow points that at a temp file and turns
it into GitHub annotations — errors for N+1s, warnings for non-scaling
repetition — plus a table in the run summary. Annotations attach to the
pull request, which is where the audience that most needs them is
looking.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Without displayDetailsOnTestsThatTriggerWarnings PHPUnit prints only a
count — 'Warnings: 11' — which is visible but not actionable. The first
warning of a run now carries the pointer to --display-warnings and the
config setting.
Once per run, not per finding: tests run with processIsolation, so each
test is a separate process and a static flag cannot track 'first'. A
marker file keyed to the project and the hour serves as the shared
signal.
@imorland
imorland merged commit a9b5d25 into 2.xJul 31, 2026
25 checks passed
@imorland
imorland deleted the im/testing-n1-detector branch July 31, 2026 21:00
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
* Document N+1 query detection in integration tests
Companion to flarum/framework#4871, which fails integration tests whose
requests run N+1 queries. Explains how to read a failure, what the
binding count means, and how to exempt a legitimately repeated query
shape.
* Distinguish N+1 failures from non-scaling query warnings
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
* Document where query findings surface
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@imorland@StyleCIBot
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) by imorland · Pull Request #4871 · flarum/framework · GitHub
Skip to content

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) - #4871

Merged
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector
Jul 31, 2026
Merged

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)#4871
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector

Conversation

@imorland

@imorlandimorland commented Jul 31, 2026

Copy link
Copy Markdown
Member

Changes proposed in this pull request

Every request sent through Flarum\Testing\integration\TestCase::send() is now inspected for N+1 query patterns, and the test fails when one is found.

The motivation is concrete: while profiling discussion-view performance this week, one extension turned out to be issuing a query per post on every page of every discussion, and another declared default includes that were never eager-loaded. Both were found by hand-profiling a live forum — no test noticed, because no test could. Extension authors should get that feedback while writing the feature.

And it immediately proved the point. On this PR's first CI run the detector failed two tests in flarum/flags — a bundled extension nobody had profiled, whose suite was green. GET /api/flags declares post.discussion and post.user as default includes but never eager-loaded what those nested resources need: core's DiscussionResource eager-loads the actor's discussion state on its own endpoints, and that doesn't carry over when a discussion is included by another resource. So every flag on the moderation page read discussion_user on its own, and the flag authors' groups were re-fetched per flag. That fix is included here (FlagResource), because a detector that lands with a known finding allow-listed on day one teaches the wrong lesson.

How it detects. An N+1 is one query shape executed once per record. RepeatedQueryDetector groups a request's query log by normalised SQL (IN lists collapsed so batched loads of differing sizes count as one shape, numeric and quoted literals replaced, transaction/savepoint noise skipped) and fails when a shape repeats past a threshold (default 5).

Two tiers, decided by whether the work grows with the data. Bindings are counted rather than folded into the shape, and that count is what separates a defect from mere waste:

SignatureMeaningResult
distinct bindings ≈ executions (10x, 10 distinct)one query per record — add rows, add queriesfails
a few values repeated (8x, 1 distinct)wasteful but bounded; five queries for two users stays five at two millionwarns

The warning is a E_USER_WARNING, which PHPUnit attributes to the triggering test — the author sees it without the build going red.

Making the warnings actually visible. As first written the warning tier was invisible three times over: the trigger_error call was @-silenced so PHPUnit never saw it, only one of eighteen phpunit configs in this repo enabled displayDetailsOnTestsThatTriggerWarnings, and a developer who relies on CI would have had to read the middle of a job log regardless. Fixed here: the @ is gone, every integration config displays warning details, and the detector appends findings to FLARUM_REPEATED_QUERY_LOG when set. The reusable backend workflow points that at a temp file and turns it into GitHub annotations (errors for N+1s, warnings for non-scaling repetition) plus a table in the run summary — so findings land on the pull request itself, where the CI-only audience is looking.

This calibration came from running the detector across every bundled extension: beyond the flags N+1 it produced 22 further findings, and all of them were the second kind (5–10 executions, 1–4 distinct values), mostly on post-save paths where a formatter resolves each mention. Failing on those would have meant rewriting formatter and write-path code for no scaling benefit — the threshold was what was wrong, not the extensions. mentions (19 findings) and subscriptions (1) now pass with warnings; the flags N+1 still fails.

On by default, with graduated escapes:

EscapeScopeWhen
allowedRepeatedQueries()one query shapePreferred — rest of the request stays covered
detectsRepeatedQueries()one test caseA test that legitimately can't satisfy it
FLARUM_DETECT_REPEATED_QUERIES=0whole runBisecting an unrelated failure

Verification

  • 11 unit tests on the detector: the real N+1 shape, batched in (…) loads of differing sizes (must not flag), sub-threshold loops, same-bindings repetition, transaction noise, inlined vs bound literals, ordering, and the message format.
  • Against a real N+1: reintroducing the per-post loading in fof/moderator-warnings fails with 10x (10 distinct bindings): select * from warningswherewarnings.post_id = ?. With the fix restored, that extension's whole suite passes with detection on.
  • Against core: tests/integration/api gives identical results with detection on and off — 353 tests, the same pre-existing failures, zero findings.
  • flags: 16 tests green with detection on, after the FlagResource fix (was 2 failures).
  • Bundled extensions: flags, mentions, subscriptions and approval all pass with detection on. tags (5) and likes (1) have pre-existing failures unrelated to queries — identical with FLARUM_DETECT_REPEATED_QUERIES=0.

Reviewers should focus on

  • The threshold (5) and whether the normalisation is too aggressive or not aggressive enough for query shapes I haven't seen.
  • The distinctBindings >= count - 1 rule that decides fail vs warn (one duplicate is tolerated, since batch loaders often re-read a single value while still doing per-record work), and whether that boundary holds for shapes I haven't seen.
  • Implementation note worth knowing: processIsolation="true" makes PHPUnit parse a subprocess's entire output as its result protocol, so printing findings — STDOUT, STDERR, /dev/tty, shutdown functions — all surface as test errors. Failing the assertion is the only channel PHPUnit sanctions here.

Developer documentation: flarum/docs#568.

Confirmed

  • Backend changes: tests are green (run composer test).

Every request sent through the integration TestCase is now inspected for
N+1 query patterns, and the test fails when it finds one. Extension
authors get the feedback while writing the feature rather than when a
forum grows: this session alone, one extension was issuing a query per
post on every page of every discussion, found only by hand-profiling a
live forum.
An N+1 is one query shape executed once per record. The detector groups
a request's query log by normalised SQL — IN lists collapsed, literals
replaced — and fails when a shape repeats past a threshold. Bindings are
counted separately rather than folded into the shape: the same SQL run
for four different users is not the same defect as one query per row,
and conflating them produces false positives (it fooled me on one
extension before this distinction existed).
On by default. A single legitimate shape can be exempted with
allowedRepeatedQueries(); a test case can override
detectsRepeatedQueries(); FLARUM_DETECT_REPEATED_QUERIES=0 disables it
for a whole run.
Verified against core's api suite: identical results with detection on
and off (353 tests, same pre-existing failures, no findings), and
against a real N+1 reintroduced in an extension, where it fails with
'10x (10 distinct bindings)' naming the offending query.
@imorland
imorland requested a review from a team as a code ownerJuly 31, 2026 16:47
The flags index declares post.discussion and post.user as default
includes, but never eager loaded what those nested resources need. Core's
DiscussionResource eager loads the actor's discussion state on its own
endpoints; that doesn't carry over when a discussion is included by
another resource. So every flag on the moderation page read
discussion_user on its own, and the flag authors' groups were re-fetched
per flag.
Caught by the N+1 detection added in this branch, on its first CI run.
@imorlandimorland changed the title [2.x] feat: fail integration tests that run N+1 queries[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)Jul 31, 2026
Running the detector across the bundled extensions turned up 22 findings
beyond the flags N+1, and they were all the same shape: a query repeated
5-10 times for only 1-4 distinct values. That is not an N+1 — five
queries for two users stays five queries whether the forum has two users
or two million. Failing on it would have meant rewriting formatter and
write-path code for no scaling benefit, so the threshold was the thing
that was wrong.
The two cases are now distinguished by the data already being collected.
Roughly as many distinct bindings as executions means one query per
record: that fails, because the work grows with the forum. A handful of
values repeated is wasteful but bounded: that raises a PHP warning, which
PHPUnit attributes to the test without failing the run.
mentions (19 findings) and subscriptions (1) now pass; the flags N+1
still fails when its fix is reverted.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
The warning tier was invisible in three separate ways. The trigger_error
call was prefixed with @, so PHPUnit never saw it at all — nothing
appeared even with --display-warnings. Only one of eighteen phpunit
configs in this repo set displayDetailsOnTestsThatTriggerWarnings, so
even a working warning printed no detail. And a developer who relies on
CI rather than local runs would have to read the middle of a job log to
find either.
So: the @ is gone, every integration config displays warning details, and
the detector appends findings to FLARUM_REPEATED_QUERY_LOG when it is
set. The reusable backend workflow points that at a temp file and turns
it into GitHub annotations — errors for N+1s, warnings for non-scaling
repetition — plus a table in the run summary. Annotations attach to the
pull request, which is where the audience that most needs them is
looking.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Without displayDetailsOnTestsThatTriggerWarnings PHPUnit prints only a
count — 'Warnings: 11' — which is visible but not actionable. The first
warning of a run now carries the pointer to --display-warnings and the
config setting.
Once per run, not per finding: tests run with processIsolation, so each
test is a separate process and a static flag cannot track 'first'. A
marker file keyed to the project and the hour serves as the shared
signal.
@imorland
imorland merged commit a9b5d25 into 2.xJul 31, 2026
25 checks passed
@imorland
imorland deleted the im/testing-n1-detector branch July 31, 2026 21:00
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
* Document N+1 query detection in integration tests
Companion to flarum/framework#4871, which fails integration tests whose
requests run N+1 queries. Explains how to read a failure, what the
binding count means, and how to exempt a legitimately repeated query
shape.
* Distinguish N+1 failures from non-scaling query warnings
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
* Document where query findings surface
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@imorland@StyleCIBot
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) by imorland · Pull Request #4871 · flarum/framework · GitHub
Skip to content

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) - #4871

Merged
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector
Jul 31, 2026
Merged

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)#4871
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector

Conversation

@imorland

@imorlandimorland commented Jul 31, 2026

Copy link
Copy Markdown
Member

Changes proposed in this pull request

Every request sent through Flarum\Testing\integration\TestCase::send() is now inspected for N+1 query patterns, and the test fails when one is found.

The motivation is concrete: while profiling discussion-view performance this week, one extension turned out to be issuing a query per post on every page of every discussion, and another declared default includes that were never eager-loaded. Both were found by hand-profiling a live forum — no test noticed, because no test could. Extension authors should get that feedback while writing the feature.

And it immediately proved the point. On this PR's first CI run the detector failed two tests in flarum/flags — a bundled extension nobody had profiled, whose suite was green. GET /api/flags declares post.discussion and post.user as default includes but never eager-loaded what those nested resources need: core's DiscussionResource eager-loads the actor's discussion state on its own endpoints, and that doesn't carry over when a discussion is included by another resource. So every flag on the moderation page read discussion_user on its own, and the flag authors' groups were re-fetched per flag. That fix is included here (FlagResource), because a detector that lands with a known finding allow-listed on day one teaches the wrong lesson.

How it detects. An N+1 is one query shape executed once per record. RepeatedQueryDetector groups a request's query log by normalised SQL (IN lists collapsed so batched loads of differing sizes count as one shape, numeric and quoted literals replaced, transaction/savepoint noise skipped) and fails when a shape repeats past a threshold (default 5).

Two tiers, decided by whether the work grows with the data. Bindings are counted rather than folded into the shape, and that count is what separates a defect from mere waste:

SignatureMeaningResult
distinct bindings ≈ executions (10x, 10 distinct)one query per record — add rows, add queriesfails
a few values repeated (8x, 1 distinct)wasteful but bounded; five queries for two users stays five at two millionwarns

The warning is a E_USER_WARNING, which PHPUnit attributes to the triggering test — the author sees it without the build going red.

Making the warnings actually visible. As first written the warning tier was invisible three times over: the trigger_error call was @-silenced so PHPUnit never saw it, only one of eighteen phpunit configs in this repo enabled displayDetailsOnTestsThatTriggerWarnings, and a developer who relies on CI would have had to read the middle of a job log regardless. Fixed here: the @ is gone, every integration config displays warning details, and the detector appends findings to FLARUM_REPEATED_QUERY_LOG when set. The reusable backend workflow points that at a temp file and turns it into GitHub annotations (errors for N+1s, warnings for non-scaling repetition) plus a table in the run summary — so findings land on the pull request itself, where the CI-only audience is looking.

This calibration came from running the detector across every bundled extension: beyond the flags N+1 it produced 22 further findings, and all of them were the second kind (5–10 executions, 1–4 distinct values), mostly on post-save paths where a formatter resolves each mention. Failing on those would have meant rewriting formatter and write-path code for no scaling benefit — the threshold was what was wrong, not the extensions. mentions (19 findings) and subscriptions (1) now pass with warnings; the flags N+1 still fails.

On by default, with graduated escapes:

EscapeScopeWhen
allowedRepeatedQueries()one query shapePreferred — rest of the request stays covered
detectsRepeatedQueries()one test caseA test that legitimately can't satisfy it
FLARUM_DETECT_REPEATED_QUERIES=0whole runBisecting an unrelated failure

Verification

  • 11 unit tests on the detector: the real N+1 shape, batched in (…) loads of differing sizes (must not flag), sub-threshold loops, same-bindings repetition, transaction noise, inlined vs bound literals, ordering, and the message format.
  • Against a real N+1: reintroducing the per-post loading in fof/moderator-warnings fails with 10x (10 distinct bindings): select * from warningswherewarnings.post_id = ?. With the fix restored, that extension's whole suite passes with detection on.
  • Against core: tests/integration/api gives identical results with detection on and off — 353 tests, the same pre-existing failures, zero findings.
  • flags: 16 tests green with detection on, after the FlagResource fix (was 2 failures).
  • Bundled extensions: flags, mentions, subscriptions and approval all pass with detection on. tags (5) and likes (1) have pre-existing failures unrelated to queries — identical with FLARUM_DETECT_REPEATED_QUERIES=0.

Reviewers should focus on

  • The threshold (5) and whether the normalisation is too aggressive or not aggressive enough for query shapes I haven't seen.
  • The distinctBindings >= count - 1 rule that decides fail vs warn (one duplicate is tolerated, since batch loaders often re-read a single value while still doing per-record work), and whether that boundary holds for shapes I haven't seen.
  • Implementation note worth knowing: processIsolation="true" makes PHPUnit parse a subprocess's entire output as its result protocol, so printing findings — STDOUT, STDERR, /dev/tty, shutdown functions — all surface as test errors. Failing the assertion is the only channel PHPUnit sanctions here.

Developer documentation: flarum/docs#568.

Confirmed

  • Backend changes: tests are green (run composer test).

Every request sent through the integration TestCase is now inspected for
N+1 query patterns, and the test fails when it finds one. Extension
authors get the feedback while writing the feature rather than when a
forum grows: this session alone, one extension was issuing a query per
post on every page of every discussion, found only by hand-profiling a
live forum.
An N+1 is one query shape executed once per record. The detector groups
a request's query log by normalised SQL — IN lists collapsed, literals
replaced — and fails when a shape repeats past a threshold. Bindings are
counted separately rather than folded into the shape: the same SQL run
for four different users is not the same defect as one query per row,
and conflating them produces false positives (it fooled me on one
extension before this distinction existed).
On by default. A single legitimate shape can be exempted with
allowedRepeatedQueries(); a test case can override
detectsRepeatedQueries(); FLARUM_DETECT_REPEATED_QUERIES=0 disables it
for a whole run.
Verified against core's api suite: identical results with detection on
and off (353 tests, same pre-existing failures, no findings), and
against a real N+1 reintroduced in an extension, where it fails with
'10x (10 distinct bindings)' naming the offending query.
@imorland
imorland requested a review from a team as a code ownerJuly 31, 2026 16:47
The flags index declares post.discussion and post.user as default
includes, but never eager loaded what those nested resources need. Core's
DiscussionResource eager loads the actor's discussion state on its own
endpoints; that doesn't carry over when a discussion is included by
another resource. So every flag on the moderation page read
discussion_user on its own, and the flag authors' groups were re-fetched
per flag.
Caught by the N+1 detection added in this branch, on its first CI run.
@imorlandimorland changed the title [2.x] feat: fail integration tests that run N+1 queries[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)Jul 31, 2026
Running the detector across the bundled extensions turned up 22 findings
beyond the flags N+1, and they were all the same shape: a query repeated
5-10 times for only 1-4 distinct values. That is not an N+1 — five
queries for two users stays five queries whether the forum has two users
or two million. Failing on it would have meant rewriting formatter and
write-path code for no scaling benefit, so the threshold was the thing
that was wrong.
The two cases are now distinguished by the data already being collected.
Roughly as many distinct bindings as executions means one query per
record: that fails, because the work grows with the forum. A handful of
values repeated is wasteful but bounded: that raises a PHP warning, which
PHPUnit attributes to the test without failing the run.
mentions (19 findings) and subscriptions (1) now pass; the flags N+1
still fails when its fix is reverted.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
The warning tier was invisible in three separate ways. The trigger_error
call was prefixed with @, so PHPUnit never saw it at all — nothing
appeared even with --display-warnings. Only one of eighteen phpunit
configs in this repo set displayDetailsOnTestsThatTriggerWarnings, so
even a working warning printed no detail. And a developer who relies on
CI rather than local runs would have to read the middle of a job log to
find either.
So: the @ is gone, every integration config displays warning details, and
the detector appends findings to FLARUM_REPEATED_QUERY_LOG when it is
set. The reusable backend workflow points that at a temp file and turns
it into GitHub annotations — errors for N+1s, warnings for non-scaling
repetition — plus a table in the run summary. Annotations attach to the
pull request, which is where the audience that most needs them is
looking.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Without displayDetailsOnTestsThatTriggerWarnings PHPUnit prints only a
count — 'Warnings: 11' — which is visible but not actionable. The first
warning of a run now carries the pointer to --display-warnings and the
config setting.
Once per run, not per finding: tests run with processIsolation, so each
test is a separate process and a static flag cannot track 'first'. A
marker file keyed to the project and the hour serves as the shared
signal.
@imorland
imorland merged commit a9b5d25 into 2.xJul 31, 2026
25 checks passed
@imorland
imorland deleted the im/testing-n1-detector branch July 31, 2026 21:00
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
* Document N+1 query detection in integration tests
Companion to flarum/framework#4871, which fails integration tests whose
requests run N+1 queries. Explains how to read a failure, what the
binding count means, and how to exempt a legitimately repeated query
shape.
* Distinguish N+1 failures from non-scaling query warnings
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
* Document where query findings surface
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@imorland@StyleCIBot
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) by imorland · Pull Request #4871 · flarum/framework · GitHub
Skip to content

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found) - #4871

Merged
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector
Jul 31, 2026
Merged

[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)#4871
imorland merged 6 commits into
2.xfrom
im/testing-n1-detector

Conversation

@imorland

@imorlandimorland commented Jul 31, 2026

Copy link
Copy Markdown
Member

Changes proposed in this pull request

Every request sent through Flarum\Testing\integration\TestCase::send() is now inspected for N+1 query patterns, and the test fails when one is found.

The motivation is concrete: while profiling discussion-view performance this week, one extension turned out to be issuing a query per post on every page of every discussion, and another declared default includes that were never eager-loaded. Both were found by hand-profiling a live forum — no test noticed, because no test could. Extension authors should get that feedback while writing the feature.

And it immediately proved the point. On this PR's first CI run the detector failed two tests in flarum/flags — a bundled extension nobody had profiled, whose suite was green. GET /api/flags declares post.discussion and post.user as default includes but never eager-loaded what those nested resources need: core's DiscussionResource eager-loads the actor's discussion state on its own endpoints, and that doesn't carry over when a discussion is included by another resource. So every flag on the moderation page read discussion_user on its own, and the flag authors' groups were re-fetched per flag. That fix is included here (FlagResource), because a detector that lands with a known finding allow-listed on day one teaches the wrong lesson.

How it detects. An N+1 is one query shape executed once per record. RepeatedQueryDetector groups a request's query log by normalised SQL (IN lists collapsed so batched loads of differing sizes count as one shape, numeric and quoted literals replaced, transaction/savepoint noise skipped) and fails when a shape repeats past a threshold (default 5).

Two tiers, decided by whether the work grows with the data. Bindings are counted rather than folded into the shape, and that count is what separates a defect from mere waste:

SignatureMeaningResult
distinct bindings ≈ executions (10x, 10 distinct)one query per record — add rows, add queriesfails
a few values repeated (8x, 1 distinct)wasteful but bounded; five queries for two users stays five at two millionwarns

The warning is a E_USER_WARNING, which PHPUnit attributes to the triggering test — the author sees it without the build going red.

Making the warnings actually visible. As first written the warning tier was invisible three times over: the trigger_error call was @-silenced so PHPUnit never saw it, only one of eighteen phpunit configs in this repo enabled displayDetailsOnTestsThatTriggerWarnings, and a developer who relies on CI would have had to read the middle of a job log regardless. Fixed here: the @ is gone, every integration config displays warning details, and the detector appends findings to FLARUM_REPEATED_QUERY_LOG when set. The reusable backend workflow points that at a temp file and turns it into GitHub annotations (errors for N+1s, warnings for non-scaling repetition) plus a table in the run summary — so findings land on the pull request itself, where the CI-only audience is looking.

This calibration came from running the detector across every bundled extension: beyond the flags N+1 it produced 22 further findings, and all of them were the second kind (5–10 executions, 1–4 distinct values), mostly on post-save paths where a formatter resolves each mention. Failing on those would have meant rewriting formatter and write-path code for no scaling benefit — the threshold was what was wrong, not the extensions. mentions (19 findings) and subscriptions (1) now pass with warnings; the flags N+1 still fails.

On by default, with graduated escapes:

EscapeScopeWhen
allowedRepeatedQueries()one query shapePreferred — rest of the request stays covered
detectsRepeatedQueries()one test caseA test that legitimately can't satisfy it
FLARUM_DETECT_REPEATED_QUERIES=0whole runBisecting an unrelated failure

Verification

  • 11 unit tests on the detector: the real N+1 shape, batched in (…) loads of differing sizes (must not flag), sub-threshold loops, same-bindings repetition, transaction noise, inlined vs bound literals, ordering, and the message format.
  • Against a real N+1: reintroducing the per-post loading in fof/moderator-warnings fails with 10x (10 distinct bindings): select * from warningswherewarnings.post_id = ?. With the fix restored, that extension's whole suite passes with detection on.
  • Against core: tests/integration/api gives identical results with detection on and off — 353 tests, the same pre-existing failures, zero findings.
  • flags: 16 tests green with detection on, after the FlagResource fix (was 2 failures).
  • Bundled extensions: flags, mentions, subscriptions and approval all pass with detection on. tags (5) and likes (1) have pre-existing failures unrelated to queries — identical with FLARUM_DETECT_REPEATED_QUERIES=0.

Reviewers should focus on

  • The threshold (5) and whether the normalisation is too aggressive or not aggressive enough for query shapes I haven't seen.
  • The distinctBindings >= count - 1 rule that decides fail vs warn (one duplicate is tolerated, since batch loaders often re-read a single value while still doing per-record work), and whether that boundary holds for shapes I haven't seen.
  • Implementation note worth knowing: processIsolation="true" makes PHPUnit parse a subprocess's entire output as its result protocol, so printing findings — STDOUT, STDERR, /dev/tty, shutdown functions — all surface as test errors. Failing the assertion is the only channel PHPUnit sanctions here.

Developer documentation: flarum/docs#568.

Confirmed

  • Backend changes: tests are green (run composer test).

Every request sent through the integration TestCase is now inspected for
N+1 query patterns, and the test fails when it finds one. Extension
authors get the feedback while writing the feature rather than when a
forum grows: this session alone, one extension was issuing a query per
post on every page of every discussion, found only by hand-profiling a
live forum.
An N+1 is one query shape executed once per record. The detector groups
a request's query log by normalised SQL — IN lists collapsed, literals
replaced — and fails when a shape repeats past a threshold. Bindings are
counted separately rather than folded into the shape: the same SQL run
for four different users is not the same defect as one query per row,
and conflating them produces false positives (it fooled me on one
extension before this distinction existed).
On by default. A single legitimate shape can be exempted with
allowedRepeatedQueries(); a test case can override
detectsRepeatedQueries(); FLARUM_DETECT_REPEATED_QUERIES=0 disables it
for a whole run.
Verified against core's api suite: identical results with detection on
and off (353 tests, same pre-existing failures, no findings), and
against a real N+1 reintroduced in an extension, where it fails with
'10x (10 distinct bindings)' naming the offending query.
@imorland
imorland requested a review from a team as a code ownerJuly 31, 2026 16:47
The flags index declares post.discussion and post.user as default
includes, but never eager loaded what those nested resources need. Core's
DiscussionResource eager loads the actor's discussion state on its own
endpoints; that doesn't carry over when a discussion is included by
another resource. So every flag on the moderation page read
discussion_user on its own, and the flag authors' groups were re-fetched
per flag.
Caught by the N+1 detection added in this branch, on its first CI run.
@imorlandimorland changed the title [2.x] feat: fail integration tests that run N+1 queries[2.x] feat: fail integration tests that run N+1 queries (and fix the first one it found)Jul 31, 2026
Running the detector across the bundled extensions turned up 22 findings
beyond the flags N+1, and they were all the same shape: a query repeated
5-10 times for only 1-4 distinct values. That is not an N+1 — five
queries for two users stays five queries whether the forum has two users
or two million. Failing on it would have meant rewriting formatter and
write-path code for no scaling benefit, so the threshold was the thing
that was wrong.
The two cases are now distinguished by the data already being collected.
Roughly as many distinct bindings as executions means one query per
record: that fails, because the work grows with the forum. A handful of
values repeated is wasteful but bounded: that raises a PHP warning, which
PHPUnit attributes to the test without failing the run.
mentions (19 findings) and subscriptions (1) now pass; the flags N+1
still fails when its fix is reverted.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
The warning tier was invisible in three separate ways. The trigger_error
call was prefixed with @, so PHPUnit never saw it at all — nothing
appeared even with --display-warnings. Only one of eighteen phpunit
configs in this repo set displayDetailsOnTestsThatTriggerWarnings, so
even a working warning printed no detail. And a developer who relies on
CI rather than local runs would have to read the middle of a job log to
find either.
So: the @ is gone, every integration config displays warning details, and
the detector appends findings to FLARUM_REPEATED_QUERY_LOG when it is
set. The reusable backend workflow points that at a temp file and turns
it into GitHub annotations — errors for N+1s, warnings for non-scaling
repetition — plus a table in the run summary. Annotations attach to the
pull request, which is where the audience that most needs them is
looking.
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Without displayDetailsOnTestsThatTriggerWarnings PHPUnit prints only a
count — 'Warnings: 11' — which is visible but not actionable. The first
warning of a run now carries the pointer to --display-warnings and the
config setting.
Once per run, not per finding: tests run with processIsolation, so each
test is a separate process and a static flag cannot track 'first'. A
marker file keyed to the project and the hour serves as the shared
signal.
@imorland
imorland merged commit a9b5d25 into 2.xJul 31, 2026
25 checks passed
@imorland
imorland deleted the im/testing-n1-detector branch July 31, 2026 21:00
imorland added a commit to flarum/docs that referenced this pull request Jul 31, 2026
* Document N+1 query detection in integration tests
Companion to flarum/framework#4871, which fails integration tests whose
requests run N+1 queries. Explains how to read a failure, what the
binding count means, and how to exempt a legitimately repeated query
shape.
* Distinguish N+1 failures from non-scaling query warnings
Follows the two-tier behaviour in flarum/framework#4871: one query per
record fails the test, while a query repeated for the same few values
raises a warning instead.
* Document where query findings surface
Follows flarum/framework#4871: the example phpunit config now displays
warning details, and findings appear as pull request annotations and a run
summary in CI via FLARUM_REPEATED_QUERY_LOG.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@imorland@StyleCIBot