Skip to content

Speed up work package visibility CTE - #26131

Closed
oliverguenther wants to merge 3 commits into
devfrom
perf/speed-up-visibility
Closed

oliverguenther wants to merge 3 commits into
devfrom
perf/speed-up-visibility

Conversation

@oliverguenther

Copy link
Copy Markdown
Member

Following a demo extraction of pg_stat_statements on the community shard to make performance analysis easier to export later on, I noticed that WITH "member_projects" AS ... made up a lot of the statements records. I let claude analyze the allowed_to CTE on the public anonymized community dump.

On that dump, queries containing this CTE (visibility checks and unread notification counts) use about 50 % of the database time and write most of the temp files.

It suggests to use IN in both branches, so that each branch returns every work package at most once, and combine them with UNION ALL. Only work packages that are both in a member project and shared directly with the user can now appear twice, which the EXISTS check ignores. The CTE stays MATERIALIZED, so the LIMIT 1 planning issue it works around is not affected.

On the local dump, for a user with 263 member project rows, the CTE input drops from 22,617 rows into a HashAggregate to 10,387 rows with no aggregation. The result set is identical.

Median query times on the anonymized community dump over six runs:

  • Visible work package IDs: 38.4 → 21.7 ms
  • Any visible work package: 25.3 → 8.5 ms
  • Single work package visibility: 22.1 → 5.3 ms
  • Unread notification count: 41.9 → 25.6 ms

Ticket

None

AI involvement

Directed – I specified the requirements and AI implemented most of it; I validated via testing rather than a full line-by-line review.

Merge checklist

  • Added/updated tests
  • Added/updated documentation in Lookbook (patterns, previews, etc)
  • Tested major browsers (Chrome, Firefox, Edge, ...)

Following a demo extraction of pg_stat_statements on the community shard, I noticed that `WITH "member_projects" AS ...` made up a lot of the statements records.
I let claude analyze the allowed_to CTE on the public anonymized community dump.

On that dump, queries containing this CTE (visibility checks and unread notification counts) use about 50 % of the database
time and write most of the temp files.

It suggests to use IN in both branches, so that each branch returns every
work package at most once, and combine them with UNION ALL. Only work
packages that are both in a member project and shared directly with the
user can now appear twice, which the EXISTS check ignores. The CTE stays
MATERIALIZED, so the LIMIT 1 planning issue it works around is not
affected.

On the local dump, for a user with 263 member project rows, the CTE
input drops from 22,617 rows into a HashAggregate to 10,387 rows with no
aggregation. The result set is identical.

Median query times on the anonymized community dump over six runs:
- Visible work package IDs: 38.4 → 21.7 ms
- Any visible work package: 25.3 → 8.5 ms
- Single work package visibility: 22.1 → 5.3 ms
- Unread notification count: 41.9 → 25.6 ms
@github-actions github-actions Bot added the ai: Directed 🪄 A human specified the requirements and AI implemented most of it; They validated via testing. label Oct 19, 2026
@klaustopher

Copy link
Copy Markdown
Contributor

I was looking into this one as well when working on #25984. This can also be further optimized because this loads and materializes a list of all work packages within the allowed projects. This can be optimized again, where we just load the list of visible projects and the handful of shared work package IDs and putting the UNION one level above.

@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./modules/backlogs/spec/features/inbox_column_spec.rb[1:5:4:2:1]
🤖 Ask Copilot to investigate

Copy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer.

@copilot The following spec(s) are flaky in CI (first seen on PR #26131, linked for reference only):

- `rspec ./modules/backlogs/spec/features/inbox_column_spec.rb[1:5:4:2:1]`

Treat this as a standalone task, unrelated to PR #26131. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #26131 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @oliverguenther to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @oliverguenther, and request a review from @oliverguenther.
On every commit, set @oliverguenther as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@oliverguenther

oliverguenther commented Oct 19, 2026 •

Copy link
Copy Markdown
Member Author

I compared the first commit 231cb6e (UNION ALL) and added a second one 090f748 (plucked ids). One would think that plucking + uniq in Ruby would be way worse, but that seems not to be the case:

  • For users who see many work packages, the visibility
    queries are 2-27x faster on community, and 8-14x times faster on the largest SaaS
    instance (2.47 M work packages), measured with the wall clock in the Rails console.

  • The materialized CTE exists because of work around poor PgSQL query planning #20772: a LIMIT
    query walked a whole index for a user who sees few work packages. On the largest instance, a
    user who sees 432 work packages gets 2.6 to 3.1 ms with plucked ids, against 2.8 to 3.5 ms
    today

  • The pluck query will always run, even if the outer query is empty already.
    For a user without unread notifications, the unread notification count goes from 1.9 ms to
    2.8 ms on the largest instance, and from 3.2 ms to 4.7 ms on community

Tested benchmarks

Variant Shape Problem
A Materialized CTE (17.9, production) builds all visible work package ids, deduplicates with UNION, outer EXISTS full set on each call, also for one work package
B A with semi-joins and UNION ALL same, without duplicates still the full set on each call
C OR with subqueries (review suggestion) project_id IN (subquery) OR id IN (subquery) planner guesses a large match, walks the updated_at index for narrow users, this reintroduces the regression of #20772
D Plucked ids pluck member project ids and shared work package ids, then project_id IN (1, 4, …) OR id IN (…) always executes one additional query

C was not measured in production. It was the "small instances" strategy of #19876 and was removed
in #21256

Note

The anonymous user path is not changed. On community, SELECT work_packages.* WHERE project_id IN (...) for anonymous is 12.4 % of database time

Benchmark run

Running a benchmark script of the variants on a temporary rails console:

  • One READ ONLY transaction with statement_timeout = 60s, rolled back at the end.
  • Variants A, B and D are built in the script, so the deployed version (17.9.3) does not change
    the result.
  • EXPLAIN (ANALYZE, BUFFERS), 1 warm-up run, median of 5 runs. The runs were on the live
    production databases, not on copies.
  • exec is the server execution time in ms. For D, it includes the execution time of the pluck
    query.
  • wall is the wall clock in the Rails console: scope build, SQL build, all queries including the
    pluck query, and result loading. EXPLAIN ANALYZE adds its own timing overhead, so for A and B
    exec can be higher than wall.
  • Each query was also run without EXPLAIN. All variants returned the same rows in every case.
  • Users were picked automatically: most memberships, most unread notifications, one membership in
    a private project, no memberships.
Code path Stands for
count WorkPackage.visible(user).count
exists? visible id WorkPackage.visible(user).find(id), visibility checks
exists? hidden id the same, for a work package the user cannot see (the LIMIT 1 case of #20772)
list of 20 by updated_at work package lists and boards
unread notification count the bell and the notification center

Factor exec and factor wall in the tables below are A divided by D.

Benchmark script
# Work package visibility benchmark. Read-only: runs in a READ ONLY transaction and rolls back.
# Paste into a Rails console. On a multi-tenant shard, switch to the tenant first.
# Optional: set VISIBILITY_BENCH_USER_IDS = [1, 2, 3] before pasting to choose the users yourself.
(ActiveRecord::Base.logger || Logger.new(nil)).silence do
  runs = 5
  conn = ActiveRecord::Base.connection
  permissions = Authorization.contextual_permissions(:view_work_packages, :work_package, raise_on_unknown: true)

  member_union = ->(user) do
    Project.unscoped.allowed_to_member_union(user, permissions, entity_types: [nil, WorkPackage.name])
  end

  split_ctes = {
    entity_member_projects: Arel.sql("SELECT * FROM member_projects WHERE entity_id IS NOT NULL"),
    project_member_projects: Arel.sql("SELECT * FROM member_projects WHERE entity_id IS NULL")
  }

  materialized_cte = ->(user, allowed_sql) do
    WorkPackage
      .with(member_projects: Arel.sql(member_union.(user).to_sql),
            **split_ctes,
            allowed_by_projects_and_work_packages: Arel.sql("WITH allowed AS MATERIALIZED (#{allowed_sql}) SELECT * FROM allowed"))
      .where("EXISTS (SELECT 1 FROM allowed_by_projects_and_work_packages a WHERE a.id = work_packages.id)")
  end

  pluck_sql = ->(user) do
    Project.unscoped
           .from(Arel::Nodes::TableAlias.new(Arel::Nodes::Grouping.new(member_union.(user)), "member_projects"))
           .select("member_projects.id", "member_projects.entity_id")
           .to_sql
  end

  variants = {
    "17.9 CTE" => ->(user) {
      materialized_cte.(user, <<~SQL.squish)
        SELECT work_packages.id FROM work_packages
        JOIN project_member_projects ON project_member_projects.id = work_packages.project_id
        UNION
        SELECT work_packages.id FROM work_packages
        JOIN entity_member_projects ON entity_member_projects.entity_id = work_packages.id
      SQL
    },
    "UNION ALL" => ->(user) {
      materialized_cte.(user, <<~SQL.squish)
        SELECT work_packages.id FROM work_packages
        WHERE work_packages.project_id IN (SELECT id FROM project_member_projects)
        UNION ALL
        SELECT work_packages.id FROM work_packages
        WHERE work_packages.id IN (SELECT entity_id FROM entity_member_projects)
      SQL
    },
    "plucked ids" => ->(user) {
      project_rows, work_package_rows = conn.select_rows(pluck_sql.(user)).partition { |_, entity_id| entity_id.nil? }
      project_ids = project_rows.map { it.first.to_i }.uniq
      work_package_ids = work_package_rows.map { it.last.to_i }.uniq
      WorkPackage.where(WorkPackage.arel_table[:project_id].in(project_ids).or(WorkPackage.arel_table[:id].in(work_package_ids)))
    }
  }

  explain = ->(sql) do
    result = JSON.parse(conn.select_value("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{sql}")).first
    { exec: result["Execution Time"], plan: result["Planning Time"], temp: result["Plan"]["Temp Written Blocks"].to_i }
  end

  measure = ->(sql) do
    explain.(sql)
    samples = Array.new(runs) { explain.(sql) }
    median = ->(key) { samples.map { it[key] }.sort[runs / 2] }
    { exec: median.(:exec), plan: median.(:plan), temp: samples.map { it[:temp] }.max }
  end

  measure_wall = ->(&block) do
    clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) }
    timed = -> { started = clock.(); block.(); clock.() - started }
    timed.()
    Array.new(runs) { timed.() }.sort[runs / 2]
  end

  pick_users = -> do
    base = User.active.where(admin: false)
    {
      "most memberships" => base.joins(:members).group("users.id").order(Arel.sql("COUNT(*) DESC")).limit(1).pluck("users.id").first,
      "most unread notifications" => Notification.where(read_ian: false, recipient_id: base.select(:id))
                                                 .group(:recipient_id).order(Arel.sql("COUNT(*) DESC")).limit(1)
                                                 .pluck(:recipient_id).first,
      "one private project" => base.joins(members: :project).where(members: { entity_type: nil }, projects: { public: false })
                                   .group("users.id").having("COUNT(*) = 1").limit(1).pluck("users.id").first,
      "no memberships" => base.where.not(id: Member.select(:user_id))
                              .order(Arel.sql("last_login_on DESC NULLS LAST")).limit(1).pluck(:id).first
    }.compact
  end

  conn.uncached do ActiveRecord::Base.transaction do
    conn.execute("SET TRANSACTION READ ONLY")
    conn.execute("SET LOCAL statement_timeout = '60s'")

    puts "OpenProject #{OpenProject::VERSION} | #{conn.select_value('SHOW server_version')} | " \
         "work_mem #{conn.select_value('SHOW work_mem')} | #{WorkPackage.count} work packages | " \
         "median of #{runs} runs, times in ms, temp = max temp blocks written, " \
         "wall = scope build + SQL build + query + result loading in this console"

    user_ids = defined?(VISIBILITY_BENCH_USER_IDS) ? VISIBILITY_BENCH_USER_IDS.index_by { "user #{it}" } : pick_users.()

    user_ids.each do |label, user_id|
      user = User.find(user_id)
      pluck_cost = measure.(pluck_sql.(user))
      member_rows = conn.select_rows(pluck_sql.(user))
      project_rows, share_rows = member_rows.partition { |_, entity_id| entity_id.nil? }
      plucked_ids = "#{project_rows.map(&:first).uniq.size} project ids, #{share_rows.map(&:last).uniq.size} shared ids"
      scopes = variants.transform_values { it.(user) }
      visible = scopes["plucked ids"]
      visible_count = visible.count
      sample_id = visible.order(:id).limit(1).pluck(:id).first
      hidden_id = WorkPackage.where.not(id: visible.select(:id)).limit(1).pluck(:id).first

      puts "\n### #{label}: user #{user.id}, sees #{visible_count} work packages, " \
           "#{Notification.where(recipient_id: user.id, read_ian: false).count} unread notifications, " \
           "pluck query #{format('%.2f', pluck_cost[:exec])} ms exec (added to 'plucked ids' below), " \
           "#{format('%.2f', pluck_cost[:plan])} ms plan (not added), " \
           "#{member_rows.size} member union rows, #{plucked_ids}"
      puts format("%-26s %-12s %10s %8s %10s %6s  %s", "query", "variant", "exec", "plan", "wall", "temp", "result")

      shapes = {
        "count" => ->(s) { s.select("COUNT(*)") },
        "exists? visible id" => ->(s) { sample_id && s.where(id: sample_id).select("1 AS one").limit(1) },
        "exists? hidden id" => ->(s) { hidden_id && s.where(id: hidden_id).select("1 AS one").limit(1) },
        "list of 20 by updated_at" => ->(s) { s.select(:id).order(updated_at: :desc, id: :desc).limit(20) },
        "unread notification count" => ->(s) {
          Notification.where(recipient_id: user.id, read_ian: false, resource_type: "WorkPackage")
                      .where(resource_id: s.select(:id)).select("COUNT(notifications.id)")
        }
      }

      shapes.each do |shape, build|
        results = scopes.filter_map do |variant, scope|
          relation = build.(scope)
          next unless relation

          sql = relation.to_sql
          cost = measure.(sql)
          cost[:exec] += pluck_cost[:exec] if variant == "plucked ids"
          cost[:wall] = measure_wall.() { conn.select_values(build.(variants[variant].(user)).to_sql) }
          [variant, cost, conn.select_values(sql)]
        end
        next if results.empty?

        same = results.map(&:last).uniq.one? ? "same" : "DIFFERENT"
        results.each do |variant, cost, _|
          puts format("%-26s %-12s %10.2f %8.2f %10.2f %6d  %s",
                      shape, variant, cost[:exec], cost[:plan], cost[:wall], cost[:temp], same)
        end
      end
    end

    raise ActiveRecord::Rollback
  end end
end
nil
community (54,185 work packages, PostgreSQL 17.9, `work_mem` 4 MB)

Most memberships (sees 43,769, 26 unread notifications, 279 member union rows):

Shape A exec A wall B exec B wall D exec D wall Factor exec Factor wall
count 172.11 118.16 95.89 82.04 15.40 18.53 11 6.4
exists? visible id 88.61 62.26 9.49 10.18 1.12 5.97 79 10
exists? hidden id 111.26 72.31 30.06 23.64 1.12 6.08 99 12
list of 20 by updated_at 196.58 142.68 115.88 92.70 1.14 5.25 172 27
unread notification count 178.46 122.60 94.80 81.71 4.11 10.32 43 12

Most unread notifications (sees 29,457, 89,932 unread notifications, 37 member union rows):

Shape A exec A wall B exec B wall D exec D wall Factor exec Factor wall
count 63.66 49.92 57.95 47.11 4.26 6.63 15 7.5
exists? visible id 17.78 14.86 13.73 12.10 0.22 4.09 81 3.6
exists? hidden id 25.99 18.25 19.10 15.34 0.22 4.14 118 4.4
list of 20 by updated_at 82.68 66.92 78.17 64.72 0.26 4.14 318 16
unread notification count 123.82 96.99 116.24 97.44 38.99 40.17 3.2 2.4

One private project (sees 29,465, 38 member union rows) and, in parentheses, no memberships
(sees 29,457, 37 member union rows). Both have no unread notifications:

Shape A exec A wall B exec B wall D exec D wall Factor exec Factor wall
count 65.07 (63.87) 50.21 (49.33) 59.24 (59.80) 48.33 (47.66) 4.29 (4.34) 6.72 (6.08) 15 (15) 7.5 (8.1)
exists? visible id 18.26 (18.14) 15.31 (14.17) 14.12 (13.44) 12.48 (12.22) 0.31 (0.21) 4.21 (4.15) 59 (86) 3.6 (3.4)
exists? hidden id 26.12 (25.76) 17.84 (18.22) 19.31 (18.23) 14.75 (14.06) 0.31 (0.21) 3.98 (4.12) 84 (123) 4.5 (4.4)
list of 20 by updated_at 84.69 (82.76) 67.77 (65.46) 78.61 (76.52) 63.98 (62.80) 0.35 (0.25) 4.79 (4.52) 242 (331) 14 (14)
unread notification count 0.06 (0.06) 3.22 (2.95) 0.06 (0.06) 3.21 (2.81) 0.32 (0.22) 4.66 (4.06) 0.19 (0.27) 0.69 (0.73)

Most community projects are public, so every sampled user sees more than half of the work
packages. The narrow case does not exist there.

Largest SaaS instance (2,469,503 work packages, PostgreSQL 17.9, `work_mem` 4 MB)

Broad user (sees 63,706, 47,857 unread notifications, 45 member union rows). This user had both
the most memberships and the most unread notifications, so the script measured it twice. The second
run was within 11 % for exec. Wall varied more (D, exists? visible id: 4.16 and 9.14 ms).

Shape A exec A wall B exec B wall D exec D wall Factor exec Factor wall
count 166.09 133.27 157.21 129.46 9.02 9.38 18 14
exists? visible id 50.63 36.44 13.28 11.35 1.48 4.16 34 8.8
exists? hidden id 58.42 43.14 39.24 27.72 1.48 4.36 39 9.9
list of 20 by updated_at 215.96 182.56 208.40 171.33 21.11 23.11 10 7.9
unread notification count 466.76 426.16 462.48 424.58 49.41 48.99 9.4 8.7

Narrow user (one private project, sees 432, no unread notifications, 1 member union row):

Shape A exec A wall B exec B wall D exec D wall Factor exec Factor wall
count 1.92 3.49 1.73 3.39 1.05 2.60 1.8 1.3
exists? visible id 1.16 2.83 0.97 2.75 1.00 2.64 1.2 1.1
exists? hidden id 1.27 2.90 1.15 3.14 1.00 2.69 1.3 1.1
list of 20 by updated_at 1.93 3.52 1.84 3.54 1.13 3.11 1.7 1.1
unread notification count 0.05 1.93 0.05 1.95 1.00 2.78 0.05 0.69

No memberships (sees 0, 0 member union rows): all variants and shapes took 0.9 to 1.0 ms exec
and 2.6 to 3.0 ms wall, except the unread notification count: 0.05 ms exec (1.9 ms wall) for A and
B, 0.91 ms exec (2.89 ms wall) for D.

Growing member union (local benchmark only)

The production users above had at most 279 member union rows. How would this change when memberships increase for instances with huge number of projects? I ran a local benchmark with synthetic rows: 2 member roles per
project, so the member union has twice as many rows as there are project ids. Median of 5 runs with query cache disabled.

Memberships Pluck (transfer and cast) partition, map, uniq to_sql SQL size Planning Query wall Total per scope build
1,000 1.8 ms 0.2 ms 0.6 ms 4 KB 1.7 ms 7.2 ms about 10 ms
10,000 6.8 ms 1.5 ms 4.8 ms 57 KB 6.1 ms 15.2 ms about 28 ms
50,000 26.8 ms 7.6 ms 26.1 ms 331 KB 38.9 ms 64.0 ms about 125 ms

Query wall includes planning. Execution stays small, because PostgreSQL hashes the constant IN
list.

map and uniq stay rather cheap even at 50,000 memberships. The cost comes from transferring and casting
the rows, interestingly mostly from Arel quoting each id into the SQL string, and from PostgreSQL planning the long IN
list.

For comparison: a user with thousands of projects most likely sees a large part of all work
packages, and the CTE materializes all of those ids on every call. For the broad user on the
largest instance, 63,706 ids already took 36 to 426 ms wall with the CTE, depending on the query.

The same ids as one array literal (project_id = ANY('{1,4,7}'::bigint[])) halve the planning
time: 0.77 ms for 1,000 ids, 2.96 ms for 10,000, and 19.32 ms for 50,000.

conn = ActiveRecord::Base.connection
clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) }
median = ->(runs = 5, &block) { Array.new(runs) { started = clock.(); block.(); clock.() - started }.sort[runs / 2] }
explain = ->(sql) { JSON.parse(conn.select_value("EXPLAIN (ANALYZE, FORMAT JSON) #{sql}")).first }

conn.uncached do
  [1_000, 10_000, 50_000].each do |memberships|
    rows_sql = "SELECT g / 2 AS id, NULL::bigint AS entity_id FROM generate_series(1, #{memberships * 2}) g"
    pluck = median.() { conn.select_rows(rows_sql) }
    rows = conn.select_rows(rows_sql)
    split = median.() { rows.partition { |_, entity_id| entity_id.nil? }.first.map { it.first.to_i }.uniq }
    ids = rows.map { it.first.to_i }.uniq

    literal_sql = nil
    build = median.() do
      literal_sql = WorkPackage.where(WorkPackage.arel_table[:project_id].in(ids).or(WorkPackage.arel_table[:id].in([])))
                               .select("COUNT(*)").to_sql
    end
    literal_wall = median.() { conn.select_value(literal_sql) }
    literal_plan = explain.(literal_sql)

    array_sql = "SELECT COUNT(*) FROM work_packages WHERE project_id = ANY('{#{ids.join(',')}}'::bigint[])"
    array_plan = explain.(array_sql)

    puts format("%6d memberships, %6d rows, %6d ids | pluck %6.2f ms, split %6.2f ms, to_sql %6.2f ms (%4d KB) | " \
                "IN list: plan %6.2f ms, exec %6.2f ms, query wall %6.2f ms | one array literal: plan %6.2f ms, exec %6.2f ms",
                memberships, rows.size, ids.size, pluck, split, build, literal_sql.bytesize / 1024,
                literal_plan["Planning Time"], literal_plan["Execution Time"], literal_wall,
                array_plan["Planning Time"], array_plan["Execution Time"])
  end
end

58d0d86 moved  includes of BacklogBucket.for_project into Backlogs::BucketComponent.

The spec kept passing only because the association proxy is lazy. Building the
visible scope now plucks the visible project ids, which made the
stale expectation fail.
@github-actions

Copy link
Copy Markdown

Warning

Flaky specs

  • rspec ./spec/features/components/lookbook_accessibility_spec.rb[1:14:1]
  • rspec ./spec/features/work_packages/cancel_editing_spec.rb[1:5]
🤖 Ask Copilot to investigate

Copy the prompt below into a new comment on this PR to delegate the investigation to GitHub Copilot. It will look into the flakiness and open a separate pull request with you as reviewer.

@copilot The following spec(s) are flaky in CI (first seen on PR #26131, linked for reference only):

- `rspec ./spec/features/components/lookbook_accessibility_spec.rb[1:14:1]`
- `rspec ./spec/features/work_packages/cancel_editing_spec.rb[1:5]`

Treat this as a standalone task, unrelated to PR #26131. Create a new branch from origin/dev and open a new pull request targeting dev — do not stack it on PR #26131 or reuse that branch.

Follow the playbook in docs/development/testing/handling-flaky-tests/README.md to find the root cause and fix the underlying race — do not skip, delete, or weaken the spec to make it pass; disabling is a last resort per the playbook, and only with a bug ticket. Verify the fix by running the spec(s) repeatedly (e.g. `script/bulk_run_rspec --run-count 10`).

If you cannot reproduce the flake or are not confident in a fix after reasonable investigation, do not fabricate a change or skip the spec to force CI green. Instead, leave the pull request in draft and document what you tried, the suspected cause, and any leads in its description, then assign @oliverguenther to take over.

Once the fix is verified, title the PR after the spec(s) it fixes, and use the PR description to explain the root cause, how the change resolves it, and the before/after results. Label the PR `flaky-spec`, assign @oliverguenther, and request a review from @oliverguenther.
On every commit, set @oliverguenther as the sole co-author with a `Co-authored-by:` trailer (use their GitHub no-reply email so it links to their account), so it is traceable who dispatched the fix.

@oliverguenther
oliverguenther requested a review from tisba October 20, 2026 06:05
@top4ek

top4ek commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Related #25207?

@ulferts ulferts left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is good. It is always hard to tell with these kind of improvements as there might be a combination of projects, members, permissions, .... out there where this performs worse than before. But at least on the data I have available it performed significantly better. So 🎉 . Well done, Oliver.

I have added one suggestion which might be worth comparing to the results in this PR.

.pluck("member_projects.id", "member_projects.entity_id")
.partition { |_, entity_id| entity_id.nil? }

[project_rows.map(&:first).uniq, work_package_rows.map(&:last).uniq]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pluck can be avoided:

      def logged_in_non_admin_allowed_to(user, permissions)
        member_projects = Arel::Table.new(:member_projects)
        project_ids = member_projects.project(member_projects[:id]).where(member_projects[:entity_id].eq(nil))
        work_package_ids = member_projects.project(member_projects[:entity_id])

        with(member_projects: member_projects_relation(user, permissions))
          .where(arel_table[:project_id].eq(any_of(project_ids)).or(arel_table[:id].eq(any_of(work_package_ids))))
      end

      def member_projects_relation(user, permissions)
        union = Project
                  .unscoped
                  .allowed_to_member_union(user, permissions, entity_types: [nil, WorkPackage.name])

        Project
          .unscoped
          .from(Arel::Nodes::TableAlias.new(Arel::Nodes::Grouping.new(union), "member_projects"))
          .select(Arel.star)
      end

      def any_of(subquery)
        Arel::Nodes::NamedFunction.new("ANY", [Arel::Nodes::NamedFunction.new("ARRAY", [subquery.ast])])
      end

At least on my local machine, there was not that much difference. Sometimes, the pluck performed better, sometimes the ANY did. But a solution that avoids an extra round trip between application server and DB server could be faster on a production infrastructure.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still trying to take it all in, but my gut feeling also suggest that this kind of round trip should be avoided. It's at least worth a try to test against a realistic data set.

@oliverguenther

oliverguenther commented Oct 20, 2026 •

Copy link
Copy Markdown
Member Author
Variant Shape Problem
E ANY subquery (review suggestion) member union as CTE, then project_id = ANY(ARRAY(SELECT id …)) OR id = ANY(ARRAY(SELECT entity_id …)) in one query planner cannot see the size of the arrays in advance

I added E to the benchmark script and ran that patch against community and the largest instance, Claude suggest to continue using D after all:

  1. E is very slow for users that see few or no work packages.
  2. D is much faster than the CTE for users who see many work packages. By wall clock, it is 2 to 27 times faster on community and 7 to 14 times faster on the largest instance.
  3. D is a SLOWER for users who see few or no work packages due to the second query. D is up to about 1 ms slower, because the pluck query always runs.

On community:

  • 🟢 Nice: Single work package lookups and lists ar eslightly faster (0.7 to 2.2 ms), probably simply the round trip?
  • 🟢 Nice: When the outer query finds nothing, E is as fast as the CTE it seems. So the downside of D (the pluck query always runs) goes away.
  • 🟡 Not too bad: The unread notification count with notifications is slightly slower: 17.69 ms against 10.42 ms (33 unread),
    and 56.00 ms against 38.27 ms (90,020 unread). Claude assumption is that psql choose a worse plan as it does not know the array sizes in advance.

On the largest instance, for the broad user (sees 63,706):

  • 🟢 Nice: Single work package lookups are also faster with E (3.61 to 4.02 ms against 4.65 to 5.79 ms), but the list of
    20 is slower (32.12 to 33.99 ms against 24.48 to 25.24 ms), and so is the count (23.95 to 24.51 ms against
    9.40 to 10.47 ms).
  • 🟡 Not too bad: The unread notification count is slower too: 55.17 to 55.79 ms against 50.72 to 50.83 ms.
  • 🔴 Significantly worse: User that can see only few work packages: on the largest instance, which is the case of work around poor PgSQL query planning #20772: the list of 20 by updated_at for a user who sees 432 of 2,470,642 work packages took 838.01 ms with E, against 5.02 ms with D and 4.38 ms with A.
  • 🔴 The user without ANY memberships also took 820.90 ms with E. So E seems to reintroduce the regression of work around poor PgSQL query planning #20772.
community, ANY subquery (54,198 work packages, PostgreSQL 17.9, `work_mem` 4 MB)

Factor wall A/D and A/E are the wall time of A divided by the wall time of D or E.

Most memberships (sees 43,781, 33 unread notifications, 279 member union rows):

Shape A wall D exec D wall E exec E wall Factor wall A/D Factor wall A/E
count 129.27 14.39 19.50 11.95 13.55 6.6 9.5
exists? visible id 57.14 1.10 5.43 1.12 3.58 11 16
exists? hidden id 68.04 1.10 5.26 1.16 3.56 13 19
list of 20 by updated_at 138.81 1.11 5.20 1.20 3.59 27 39
unread notification count 123.37 4.41 10.42 18.14 17.69 12 7.0

Most unread notifications (sees 29,467, 90,020 unread notifications, 37 member union rows):

Shape A wall D exec D wall E exec E wall Factor wall A/D Factor wall A/E
count 50.78 4.35 6.17 9.09 10.12 8.2 5.0
exists? visible id 14.65 0.22 5.02 0.23 3.04 2.9 4.8
exists? hidden id 19.38 0.22 4.19 0.24 2.75 4.6 7.0
list of 20 by updated_at 69.05 0.24 5.25 0.26 3.09 13 22
unread notification count 100.12 41.40 38.27 66.30 56.00 2.6 1.8

One private project (sees 29,475, 38 member union rows) and, in parentheses, no memberships
(sees 29,467, 37 member union rows). Both have no unread notifications:

Shape A wall D exec D wall E exec E wall Factor wall A/D Factor wall A/E
count 50.89 (49.70) 4.44 (4.59) 6.02 (6.46) 9.18 (9.45) 9.42 (10.02) 8.5 (7.7) 5.4 (5.0)
exists? visible id 13.40 (15.05) 0.30 (0.22) 3.95 (3.91) 0.31 (0.22) 2.80 (2.87) 3.4 (3.8) 4.8 (5.2)
exists? hidden id 18.38 (17.32) 0.30 (0.21) 3.84 (3.82) 0.31 (0.22) 3.17 (2.75) 4.8 (4.5) 5.8 (6.3)
list of 20 by updated_at 67.70 (66.54) 0.33 (0.23) 4.19 (4.08) 0.36 (0.28) 3.01 (2.88) 16 (16) 22 (23)
unread notification count 2.96 (3.43) 0.31 (0.26) 4.18 (4.86) 0.05 (0.10) 2.63 (3.16) 0.71 (0.71) 1.1 (1.1)

Variant E as added to the benchmark script:

"ANY subquery" => ->(user) {
  member_projects = Arel::Table.new(:member_projects)
  any_of = ->(subquery) { Arel::Nodes::NamedFunction.new("ANY", [Arel::Nodes::NamedFunction.new("ARRAY", [subquery.ast])]) }
  relation = Project.unscoped
                    .from(Arel::Nodes::TableAlias.new(Arel::Nodes::Grouping.new(member_union.(user)), "member_projects"))
                    .select(Arel.star)
  project_ids = member_projects.project(member_projects[:id]).where(member_projects[:entity_id].eq(nil))
  work_package_ids = member_projects.project(member_projects[:entity_id])
  WorkPackage
    .with(member_projects: relation)
    .where(WorkPackage.arel_table[:project_id].eq(any_of.(project_ids))
             .or(WorkPackage.arel_table[:id].eq(any_of.(work_package_ids))))
}
Largest SaaS instance, ANY subquery (2,470,642 work packages, PostgreSQL 17.9, `work_mem` 4 MB)

Factor wall A/D and A/E are the wall time of A divided by the wall time of D or E.

Broad user (sees 63,706, 47,857 unread notifications, 45 member union rows). This user had both the most
memberships and the most unread notifications, so the script measured it twice. The second run was within 8 %
for exec. The ranges in the findings above include both runs.

Shape A wall D exec D wall E exec E wall Factor wall A/D Factor wall A/E
count 134.86 9.52 10.47 25.82 24.51 13 5.5
exists? visible id 37.87 1.51 5.79 1.47 4.02 6.5 9.4
exists? hidden id 42.59 1.51 4.75 1.48 3.66 9.0 12
list of 20 by updated_at 176.77 21.17 24.48 31.88 33.99 7.2 5.2
unread notification count 405.00 49.11 50.83 64.39 55.79 8.0 7.3

Narrow user (one private project, sees 432, no unread notifications, 1 member union row):

Shape A wall D exec D wall E exec E wall Factor wall A/D Factor wall A/E
count 5.37 0.97 4.15 1.02 3.34 1.3 1.6
exists? visible id 3.57 0.92 4.04 0.92 4.04 0.88 0.88
exists? hidden id 3.71 0.92 4.05 0.92 3.25 0.92 1.1
list of 20 by updated_at 4.38 1.05 5.02 833.65 838.01 0.87 0.0052
unread notification count 2.87 0.93 4.47 0.04 2.54 0.64 1.1

No memberships (sees 0, 0 member union rows):

Shape A wall D exec D wall E exec E wall Factor wall A/D Factor wall A/E
count 4.36 1.00 4.48 0.93 3.34 0.97 1.3
exists? hidden id 3.51 1.00 4.13 0.96 3.36 0.85 1.0
list of 20 by updated_at 3.53 1.00 4.21 808.43 820.90 0.84 0.0043
unread notification count 2.60 1.00 3.93 0.04 2.19 0.66 1.2

@oliverguenther
oliverguenther marked this pull request as ready for review October 20, 2026 08:20
@github-actions

Copy link
Copy Markdown

Warning

This pull request does not link an OpenProject work package.

Please add a link to the work package in the description, or reference it in the
title in square brackets, e.g. [SLUG-123] My title here.

@klaustopher

klaustopher commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

With the pluck approach, what would be the result if instead of plucking the work package IDs, we pluck the project_ids for memberships and the work package ids for shares and then do the where(project_id: project_id_set).or(where(id: work_package_id_set)). That would reduce the amount of data that is plucked by a lot.

Nevermind. This is already how it's done in your suggestion.


It would be really great if the query planner of Postgres could be nudged into the right directions as one can do with MySQL/MariaDB. I hate that a round trip like here is even possible to be a faster option

@ulferts ulferts left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for rechecking the alternative (ANY) approach @oliverguenther .

I validated your results against what I have locally and agree that pluck performs better. It also performs better than the other solutions I threw at it in frustration. So let's use pluck for the time being even when it is the least elegant solution of them all.

The only thing I would like added before merging would be a comment that using pluck is no mistake.

@oliverguenther

oliverguenther commented Oct 20, 2026 •

Copy link
Copy Markdown
Member Author

Thanks @ulferts , will add the comment. I find it hard to reason with as well. I would suggest we backport it to 17.9. so we can test it on community to notice issues before the big changes of 18.0 hit

I merged the PR manually with the commit, so that the intermediate commit can stay on this PR.

@github-actions github-actions Bot locked and limited conversation to collaborators Oct 20, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

ai: Directed 🪄 A human specified the requirements and AI implemented most of it; They validated via testing. performance

Development

Successfully merging this pull request may close these issues.

5 participants