Skip to content

[6.x] Build sibling URIs once when validating a collection tree - #15901

Open
edalzell wants to merge 8 commits into
statamic:6.xfrom
edalzell:fix/tree-uri-validation-performance
Open

edalzell wants to merge 8 commits into
statamic:6.xfrom
edalzell:fix/tree-uri-validation-performance

Conversation

@edalzell

@edalzell edalzell commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Saving a collection tree checks each moved page's URI against its siblings'. validateUniqueUris() rebuilds every sibling's URI for every moved page, and because the check runs with the URI cache off, each of those builds its parent's URI again, all the way up the tree. Moving a branch of 200 pages under a page 11 levels down works out to about 70,000 URL builds to compare a few hundred URIs.

  • Sibling URIs are built once per parent. validateUniqueUris() keeps them for each parent and reuses them for every moved page under it.
  • A page remembers its own URI while the URI cache is off. Siblings share their parent Page, so the parent's URI and its ancestors' are built once rather than once per sibling. It's only read when Tree::disableUriCache() has been called, which in core is this validation. With the cache on, Page::uri() behaves as before. It's forgotten whenever the page's entry, parent, depth, route, root flag or tree is set.

Which trees are rejected, and the URI named in the error, are unchanged.

Benchmark

Saving the tree of a structured collection of 2,011 entries, 11 levels deep with 20 pages per level and the route {parent_uri}/{slug}, on 6.42.0 and on this PR, run alternately, six runs each, every run in a fresh process (Apple M3 Pro, PHP 8.5, file cache store, Stache watcher off). It runs what CollectionTreeController::update() runs: the validation, then the save with the CollectionTreeSaved listeners. Medians:

Validation, 6.42.0 Validation, this PR Whole save, 6.42.0 Whole save, this PR Peak memory, 6.42.0 Peak memory, this PR
Move a 200-page branch under a page 11 levels down (210 pages moved) 5,637 ms 107 ms (−98%) 6,381 ms 850 ms (−87%) 62.1 MB 62.1 MB (0%)
Drag a top-level section to the end (10 pages moved) 54.6 ms 37.4 ms (−32%) 547 ms 505 ms (−8%) 60.5 MB 60.5 MB (0%)
Move one leaf page (1 page moved) 42.5 ms 40.8 ms (−4%) 128 ms 127 ms (−1%) 50.6 MB 50.5 MB (0%)

The ranges for the branch move don't overlap: validation took 5,394 to 5,934 ms against 106 to 118 ms. The one-page move is within noise (41.9 to 59.4 ms against 40.1 to 44.1 ms). Memory is unchanged; the cost was time. What's left of the branch move is the save itself, which this PR doesn't touch.

Benchmark scripts

Run from the root of a Statamic site whose default site handle is default. gen.php writes the collection and its entries, then stache:warm, then bench.php init saves the tree. run-ab.sh switches vendor/statamic/cms between two checkouts for each round.

gen.php

<?php

// Generates the treebench collection: home + 10 sections, each a spine 11 levels deep
// with 20 siblings per level. Run from the sandbox root.

$root = getcwd();
$dir = "$root/content/collections/treebench/default";
@mkdir($dir, 0755, true);

file_put_contents("$root/content/collections/treebench.yaml", <<<'YAML'
title: 'Tree Bench'
sites:
  - default
route: '{parent_uri}/{slug}'
structure:
  root: true

YAML);

$n = 0;
$make = function () use (&$n, $dir) {
    $n++;
    $id = sprintf('tb-%04d', $n);
    file_put_contents("$dir/$id.md", "---\nid: $id\ntitle: 'Page $n'\npublished: true\n---\nBody $n\n");

    return $id;
};

$level = function (int $depth) use (&$level, $make) {
    $nodes = [];
    for ($i = 0; $i < 20; $i++) {
        $node = ['entry' => $make()];
        if ($i === 0 && $depth < 11) {
            $node['children'] = $level($depth + 1);
        }
        $nodes[] = $node;
    }

    return $nodes;
};

$tree = [['entry' => $make()]];
for ($s = 0; $s < 10; $s++) {
    $tree[] = ['entry' => $make(), 'children' => $level(2)];
}

file_put_contents(__DIR__.'/tree.json', json_encode($tree));
echo "entries: $n\n";

bench.php

<?php

// Usage (from sandbox root): php bench.php <scenario>
// Scenarios: init | reorder | deep | leaf
// Prints one JSON line with ms + peak MB per phase, then restores the original tree.

use Statamic\Facades\Collection;
use Statamic\Http\Controllers\CP\Collections\CollectionTreeController;

require getcwd().'/vendor/autoload.php';
$app = require getcwd().'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
config(['queue.default' => 'sync', 'statamic.stache.watcher' => false]);

$scenario = $argv[1];
$original = json_decode(file_get_contents(__DIR__.'/tree.json'), true);

$collection = Collection::findByHandle('treebench');
$structure = $collection->structure();

if ($scenario === 'init') {
    $structure->in('default')->tree($original)->save();
    echo json_encode([
        'cache' => config('cache.default'),
        'stache' => config('statamic.stache.cache_store') ?? null,
        'watcher' => config('statamic.stache.watcher'),
        'entries' => $collection->queryEntries()->count(),
        'sample' => Statamic\Facades\Entry::find('tb-2011')?->uri(),
    ]), "\n";
    exit;
}

$contents = $original;

if ($scenario === 'reorder') {
    // Drag the first section to the end of the top level.
    $moved = array_splice($contents, 1, 1);
    $contents[] = $moved[0];
} elseif ($scenario === 'deep') {
    // Move section 1 (200 descendants) under the deepest spine page of section 2.
    $moved = array_splice($contents, 1, 1);
    $node = &$contents[1];
    while (isset($node['children'])) {
        $node = &$node['children'][0];
    }
    $node['children'] = [$moved[0]];
    unset($node);
} elseif ($scenario === 'leaf') {
    // Move one leaf from section 1's top group into section 2's top group.
    $moved = array_splice($contents[1]['children'], 19, 1);
    $contents[2]['children'][] = $moved[0];
}

$phase = function (callable $fn) {
    memory_reset_peak_usage();
    $before = memory_get_usage();
    $start = hrtime(true);
    $fn();

    return [
        'ms' => round((hrtime(true) - $start) / 1e6, 1),
        'peak_mb' => round(memory_get_peak_usage() / 1048576, 1),
        'added_mb' => round((memory_get_peak_usage() - $before) / 1048576, 1),
    ];
};

$out = ['scenario' => $scenario];

$tree = null;
$out['load'] = $phase(function () use (&$tree, $structure) {
    $tree = $structure->in('default');
    $tree->tree();
});

$controller = app(CollectionTreeController::class);
$validate = new ReflectionMethod($controller, 'validateUniqueUris');

$clone = (clone $tree)->disableUriCache()->tree($contents);
$out['moved'] = count($clone->diff()->moved());
$out['ancestryChanged'] = count($clone->diff()->ancestryChanged());

$out['validate'] = $phase(fn () => $validate->invoke($controller, (clone $tree)->disableUriCache()->tree($contents)));

$out['save'] = $phase(function () use ($tree, $structure, $contents) {
    $contents = $structure->validateTree($contents, 'default');
    $tree->tree($contents)->save();
});

$out['process_peak_mb'] = round(memory_get_peak_usage(true) / 1048576, 1);

echo json_encode($out), "\n";

$structure->in('default')->tree($original)->save();

run-ab.sh

#!/bin/bash
# run-ab.sh <site> <before-cms-path> <after-cms-path> : 6 alternating rounds per scenario
S="$(cd "$(dirname "$0")" && pwd)"
cd "$1" || exit 1
for i in 1 2 3 4 5 6; do
  for v in before after; do
    [ $v = before ] && target="$2" || target="$3"
    ln -sfn "$target" vendor/statamic/cms
    for s in leaf reorder deep; do
      php "$S/bench.php" $s >> "$S/ab-$v.jsonl" || exit 1
    done
  done
done
ln -sfn "$3" vendor/statamic/cms

Tests

UpdateCollectionTreeTest counts the URL builds while two sibling pages under one parent are swapped. On 6.x each sibling's URI is built twice and their parent's 4 times; with this PR every page's URI is built once. PageTest checks that a page's URI follows a change of route while the URI cache is off. The tests were committed before the changes they cover, so they can be seen failing.

Closes #9236

@edalzell
edalzell marked this pull request as ready for review November 14, 2026 05:19
@edalzell edalzell linked an issue Nov 14, 2026 that may be closed by this pull request

This branch has not been deployed

No deployments
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.

Improve validate unique uri's in CollectionTreeController

1 participant