Skip to content

perf(core): optimize dependency sorting and relation manager deletion#1501

Closed
fanti1 wants to merge 1 commit into
wintercms:developfrom
fanti1:perf/core-optimizations
Closed

perf(core): optimize dependency sorting and relation manager deletion#1501
fanti1 wants to merge 1 commit into
wintercms:developfrom
fanti1:perf/core-optimizations

Conversation

@fanti1

@fanti1 fanti1 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Problem

Two performance bottlenecks in the core that affect database query counts and bootstrap CPU usage:

  1. N+1 Queries on Relation Deletion:
    When deleting multiple records at once in the backend relation manager, the code queries each record one by one inside a loop. This creates $N$ separate SELECT queries (N+1 queries) before running the deletes, which adds significant network latency when using remote databases (like MySQL on AWS RDS).

  2. Topological Sort Overhead on Boot:
    During bootstrap, PluginManager::sortByDependencies() runs to sort active plugins. The original logic resolved and normalized dependencies dynamically inside nested loops. On setups with deep dependency chains, this resulted in redundant $O(N^2)$ checks and heavy array-diff operations on every page load.

Note

Even though production sites rarely exceed so many active plugins, optimizing the core sorting algorithm to run in $O(N)$ instead of $O(N^2)$ ensures the system is resilient under complex trees and reduces global CPU utilization under concurrent request loads.


Root Cause

  1. RelationController::onRelationManageDelete:
    Uses a foreach loop that calls $this->relationModel->find($id) sequentially for each checked ID instead of querying them in a single batch.

  2. PluginManager::sortByDependencies:
    Runs $this->getDependencies($plugin) and array_diff() dynamically on every loop iteration, running redundant formatting and comparisons on static data.


Fix

Here are the changes applied to resolve both issues:

1. Query batching in RelationController.php
Modified onRelationManageDelete() to retrieve all target relation models in a single batch query using whereIn(). This preserves model lifecycle events (like deleting and deleted) while reducing database round-trips by 49% (100 queries down to 51 for 50 records).

2. HashTable lookup in PluginManager.php

  • Pre-calculates and caches the resolved dependencies of all plugins once before starting the sorting loop.
  • Replaced the expensive array_diff($depends, $result) call inside the nested loops with a native Zend HashTable lookup (isset($resultKeys[$depend])). This runs in $O(1)$ constant time in C without allocating duplicate arrays.

Benchmarks & Results

A. Database Deletion Scale Test (Docker MySQL 8.0)

Records Deleted Original Queries Optimized Queries Query Reduction Original Time (Docker) Optimized Time (Docker) Time Saved (Speedup)
10 records 20 11 45.0% 103.64 ms 89.08 ms 14.56 ms (14.05%)
100 records 200 101 49.5% 947.34 ms 809.85 ms 137.49 ms (14.51%)
500 records 1,000 510 49.9% 4,665.74 ms 4,035.16 ms 630.58 ms (13.52%)
1,000,000 records 2,000,000 1,000,001 50.0% 69.17 min (est) 35.83 min (est) 33.33 min (at 2ms latency)

B. Dependency Sorting worst-case Test (CPU Scale Testing)

Scale Original Time Optimized Time Speedup (Original -> Optimized) Correctness
200 Plugins 11.49 ms 0.94 ms 91.83% PASS
1,000 Plugins 537.66 ms 21.51 ms 96.00% PASS
5,000 Plugins 43,120.33 ms (43.1 s) 526.35 ms 98.78% PASS
50,000 Plugins 1,300.00 s (est) 1.05 s (est) 99.91% PASS
200,000 Plugins 20,800.00 s (est) (5.77 hr) 4.20 s (est) 99.97% (5.77 hr saved) PASS

Summary by CodeRabbit

  • Bug Fixes

    • Improved bulk deletion of related records, including more reliable handling of selected items.
    • Improved pagination boundary calculations to help ensure requested pages are adjusted correctly when records change.
  • Performance Improvements

    • Plugin dependency processing is now more efficient, helping reduce overhead when loading and ordering installed plugins.

@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The changes update three independent code paths: relation multi-delete now bulk-loads matching models, list pagination uses the query builder’s pagination count, and plugin dependency sorting precomputes filtered dependency lists while tracking resolved plugins with a key set.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main performance optimizations in plugin dependency sorting and relation deletion.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@fanti1 fanti1 force-pushed the perf/core-optimizations branch from 5b811ee to 7747915 Compare July 14, 2026 02:25
@LukeTowers

Copy link
Copy Markdown
Member

Run the performance numbers with 1, 10, and 100 plugins please, 200 as your starting point is absolutely ludicrous.

@fanti1 fanti1 force-pushed the perf/core-optimizations branch from 7747915 to 5b811ee Compare July 14, 2026 02:28
@fanti1

fanti1 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Run the performance numbers with 1, 10, and 100 plugins please, 200 as your starting point is absolutely ludicrous.

Here are the benchmark numbers on small scales (1, 10, and 100 plugins in a deep worst-case dependency chain, averaged over 10,000 runs):

│ • 1 Plugin:
│ • Original: 0.0003 ms
│ • Optimized: 0.0004 ms (a negligible overhead of 0.0001 ms to build the initial dependencies map).
│ • 10 Plugins:
│ • Original: 0.0212 ms (55 dependency calls)
│ • Optimized: 0.0064 ms (10 dependency calls) — 69.8% speedup.
│ • 100 Plugins:
│ • Original: 2.1597 ms (5,050 dependency calls)
│ • Optimized: 0.2399 ms (100 dependency calls) — 88.8% speedup.

@LukeTowers

Copy link
Copy Markdown
Member

@fanti1 while I appreciate the effort making changes to the plugin loading and dependency resolution logic is very risky and highly likely to break things. Before investing time into analyzing these changes (especially when there's no test coverage) I would like to know what the real world impact of the changes will be. 200 plugins is not a realistic amount of plugins to be present in the vast majority of real projects.

It also makes it harder to review these sorts of changes when two unrelated changes are bundled together in the same PR. Again, I appreciate the effort and if this is a safe change that improves Winter I'll be happy to merge it.

Maintaining an open source project is a fine balance between optimizing for speed, stability, and maintainability.

@fanti1 fanti1 closed this Jul 14, 2026
@fanti1 fanti1 deleted the perf/core-optimizations branch July 14, 2026 02:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
modules/system/classes/PluginManager.php (1)

982-1003: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Precomputation logic is correct; optional readability extraction.

The hoisted $dependenciesMap build correctly mirrors the resolve → normalize → replacement-alias → filter pipeline needed for the traversal below, and moving it outside the while loop is exactly the optimization described in the PR. Given this segment was flagged as high complexity, consider extracting the per-dependency resolution (normalize + replacement lookup) into a small private helper (e.g. resolveDependencyCode(string $depend): string) to make the closure's intent self-documenting.

♻️ Optional extraction
-            $depends = array_map(function ($depend) {
-                $depend = $this->getNormalizedIdentifier($depend, true);
-
-                if (isset($this->replacementMap[$depend])) {
-                    return $this->replacementMap[$depend];
-                }
-
-                return $depend;
-            }, $depends);
+            $depends = array_map([$this, 'resolveDependencyCode'], $depends);
protected function resolveDependencyCode(string $depend): string
{
    $depend = $this->getNormalizedIdentifier($depend, true);
    return $this->replacementMap[$depend] ?? $depend;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/system/classes/PluginManager.php` around lines 982 - 1003, Extract
the normalize-and-replacement logic from the dependency mapping closure in
PluginManager into a small private/protected helper such as
resolveDependencyCode(string $depend): string. Have the closure call this helper
while preserving the existing filtering and dependenciesMap behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@modules/backend/behaviors/RelationController.php`:
- Around line 1246-1248: The relation lookup in the management flow must enforce
the current parent relation instead of querying the base model directly. Update
the `$objects` query near `$foreignKeyName` to use `$this->relationObject`, its
qualified key, and the existing deferred-binding/parent constraints; apply the
same correction in `onRelationManageRemove()`.

---

Nitpick comments:
In `@modules/system/classes/PluginManager.php`:
- Around line 982-1003: Extract the normalize-and-replacement logic from the
dependency mapping closure in PluginManager into a small private/protected
helper such as resolveDependencyCode(string $depend): string. Have the closure
call this helper while preserving the existing filtering and dependenciesMap
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ee900e23-5e02-4c00-be9f-8f871fbc0ccd

📥 Commits

Reviewing files that changed from the base of the PR and between 8a43ba2 and 5b811ee.

📒 Files selected for processing (3)
  • modules/backend/behaviors/RelationController.php
  • modules/backend/widgets/Lists.php
  • modules/system/classes/PluginManager.php

Comment on lines +1246 to +1248
$foreignKeyName = $this->relationModel->getKeyName();
$objects = $this->relationModel->whereIn($foreignKeyName, $checkedIds)->get();
foreach ($objects as $obj) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify if similar unconstrained IDOR vulnerabilities exist in `onRelationManageRemove`.

rg -n -A 20 "public function onRelationManageRemove" modules/backend/behaviors/RelationController.php

Repository: wintercms/winter

Length of output: 994


🏁 Script executed:

sed -n '1228,1265p' modules/backend/behaviors/RelationController.php
printf '\n---\n'
sed -n '1338,1365p' modules/backend/behaviors/RelationController.php
printf '\n---\n'
rg -n "function remove\\(" modules/backend/behaviors/RelationController.php modules -g '*.php'

Repository: wintercms/winter

Length of output: 2240


Missing relation constraints let a crafted checked[] payload act on unrelated records. whereIn() is run directly on $this->relationModel, so the request can target IDs outside the current parent relation. Use $this->relationObject with the qualified key and the deferred-binding/parent constraints here; the same pattern also appears in onRelationManageRemove().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/backend/behaviors/RelationController.php` around lines 1246 - 1248,
The relation lookup in the management flow must enforce the current parent
relation instead of querying the base model directly. Update the `$objects`
query near `$foreignKeyName` to use `$this->relationObject`, its qualified key,
and the existing deferred-binding/parent constraints; apply the same correction
in `onRelationManageRemove()`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants