Skip to content

[2.x] perf(mentions): eager load the discussion of mentioning posts - #4876

Merged
imorland merged 1 commit into
2.xfrom
im/mentionedby-eager-load
Aug 1, 2026
Merged

[2.x] perf(mentions): eager load the discussion of mentioning posts#4876
imorland merged 1 commit into
2.xfrom
im/mentionedby-eager-load

Conversation

@imorland

@imorland imorland commented Aug 1, 2026

Copy link
Copy Markdown
Member

The problem

Serializing a post's mentionedBy relation runs a visibility check on every mentioning post, and PostPolicy::can() reads $post->discussion to do it. Those posts are materialised by the relation load without that relation, so each one fetched the same discussion over again. With tags enabled, its discussion policy then read $discussion->tags per post as well.

On a 54-post discussion with 52 mentions, 34 of 77 queries were these repeats — all with identical bindings, i.e. the same discussion fetched 17 times.

I confirmed the mechanism by object identity rather than inference: post 6353 existed as two PHP instances, one eager-loaded (discussion present) and one bare (discussion null). The bare copies are the ones the policy checks.

The fix

Eager load discussion on the relationship's own scope.

Why the scope and not the endpoint's eagerLoad() — this is the part worth reviewing. The scope flows into EloquentBuffer::loadLimitedBelongsToMany(), which resolves a windowed set of keys first and only then fetches full rows, so the eager load applies to a bounded set. Putting it on the endpoint instead routes through loadMissing(), which is unbounded: I tried that first, and a thread with 1,402 mentions pointing at one post went from 46 to 5,618 queries and 4.6s.

Why discussion and not discussion.tags — loading discussion is sufficient to remove the tags queries too, because the tags relation then batch-loads across the discussions already in memory. Loading discussion.tags directly would crash any install without the tags extension, where Discussion has no tags relation. That version passed against my dev forum (tags enabled) and only failed once the integration test ran it on a core-only install — see below.

Results

A/B by reverting the change, on an install with 74 extensions and tags enabled:

discussion before after
54 posts, 52 mentions 77 queries, 16.6 ms 45 queries, 11.0 ms
73 posts, 69 mentions 81 queries, 17.3 ms 47 queries, 11.4 ms
112 posts, 100 mentions 61 queries, 21.0 ms 45 queries, 11.1 ms
1444 posts, 1402 mentions 46 queries, 21.0 ms 40 queries, 18.9 ms
69 posts, 1 mention (control) 28 queries 28 queries

Both policy call sites disappear from the query attribution entirely. The high-fan-in case improves rather than regressing, which is the property the endpoint-level version failed.

Testing

Two integration tests, written RED first. The failing run showed 10x (1 distinct bindings): select * from "discussions" where "discussions"."id" = ? — the N+1 detector added in flarum/testing flagged it independently of my own assertion.

  • 84 mentions integration tests pass; N+1 detector warnings across the suite drop from 12 to 10, so no new patterns are introduced
  • 130 core post/discussion integration tests pass
  • PHPStan clean from the monorepo root
  • Live JSON responses byte-identical before/after on all five discussions above

Note: tags has one pre-existing full-suite failure (ListTest::admin_sees_all) from test-ordering interference. I verified it fails identically on a clean tree — unrelated to this change.

Context

Second of the findings from profiling the "discussion views feel slow" reports, after #4875 (model casts memoisation). This is a query-count and DB-load win; the wall-time gain is modest because these are sub-millisecond primary-key lookups, but it removes work that scales with mention density on exactly the long, mention-heavy threads that prompted the reports.

Serializing a post's "mentionedBy" relation runs a visibility check on
every mentioning post, and that check reads the post's discussion. Those
posts are materialised by the relation load without it, so each one
fetched the same discussion again — and once tags is enabled, its
discussion policy fetched that discussion's tags per post too.

On a 54-post discussion with 52 mentions, 34 of 77 queries were these
repeats, all with identical bindings.

Eager loading discussion on the relationship's own scope fixes it. The
scope flows into EloquentBuffer::loadLimitedBelongsToMany(), which
resolves a windowed set of keys before fetching full rows, so the eager
load applies to a bounded set. Putting it on the endpoint's eagerLoad()
instead would route through loadMissing() and drag in every mentioning
post: a thread with 1402 mentions to one post went from 46 to 5618
queries that way.

Loading discussion is enough to remove the tags queries as well, since
the tags relation then batch loads across the discussions already in
memory. Loading discussion.tags directly would break installs without
the tags extension, where Discussion has no tags relation at all.

  discussion             before      after
  54 posts, 52 mentions  77 queries  45 queries
  73 posts, 69 mentions  81 queries  47 queries
  112 posts              61 queries  45 queries
  1444 posts, 1402 ment. 46 queries  40 queries
@imorland
imorland requested a review from a team as a code owner August 1, 2026 08:19
@imorland imorland changed the title perf(mentions): eager load the discussion of mentioning posts [2.x] perf(mentions): eager load the discussion of mentioning posts Aug 1, 2026
@imorland imorland added this to the 2.0.0-rc.6 milestone Aug 1, 2026
@imorland
imorland merged commit 9793bec into 2.x Aug 1, 2026
25 checks passed
@imorland
imorland deleted the im/mentionedby-eager-load branch August 1, 2026 08:31
Sign up for free to 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.

1 participant