Skip to content

refactor: No normalized path interning - #375

Open
nielsenko wants to merge 3 commits into
serverpod:mainfrom
nielsenko:refactor-no-normalized-path-interning
Open

refactor: No normalized path interning#375
nielsenko wants to merge 3 commits into
serverpod:mainfrom
nielsenko:refactor-no-normalized-path-interning

Conversation

@nielsenko

@nielsenko nielsenko commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Description

Remove NormalizedPath interning

This PR removes NormalizedPath interning completely.

Interning was originally added to avoid re-normalizing paths, but it always had two problems:

On top of that, the new NormalizedPath.fromSegments and NormalizedPath.fromUri constructors - now the ones actually used on the request path - made interning tricky, so the security PRs (#369 & #371) simply skipped it for those paths.

Most of the lost performance is recouped by:

  • Pre-computing repeated work.
  • Extracting parameters into a single mutable map with undo-in-place during backtracking. Previously a copy was made at each parameter level so backtracking could just throw it away, but undoing in place avoids the copy and the allocation, reducing GC pressure.
  • Inferring from the registered routes whether backtracking is needed at all, and taking a faster non-backtracking walk when it isn't.

Results

┌────────┬─────────┬─────────┬────────────┐
│ routes │ Router  │ Spanner │ Routingkit │
├────────┼─────────┼─────────┼────────────┤
│ 1M     │ 1229 ns │ 1393 ns │ 3468 ns    │
├────────┼─────────┼─────────┼────────────┤
│ 100k   │ 994 ns  │ 1015 ns │ 3194 ns    │
├────────┼─────────┼─────────┼────────────┤
│ 10k    │ 579 ns  │ 402 ns  │ 1889 ns    │
└────────┴─────────┴─────────┴────────────┘
(measured on a 2.4 GHz 8-core Intel Core i9)

Fixes: #118 (not by improving caching, but by dropping it)
Fixes: #342 (this time by disabling interning altogether, instead of making caching opt-out)
Closes: #344 (investigation done)
Fixes: #374

Breaking changes

  • NormalizedPath.interned is removed

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c51b4ec-2fd5-412f-a7d6-53529fb4fc69

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@nielsenko nielsenko self-assigned this Aug 20, 2026
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.65217% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.63%. Comparing base (a623f2d) to head (c4cba4d).

Files with missing lines Patch % Lines
packages/relic_core/lib/src/router/relic_app.dart 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #375      +/-   ##
==========================================
- Coverage   92.74%   92.63%   -0.12%     
==========================================
  Files         110      110              
  Lines        4687     4736      +49     
  Branches     2373     2391      +18     
==========================================
+ Hits         4347     4387      +40     
- Misses        340      349       +9     
Flag Coverage Δ
relic_core 92.57% <95.65%> (-0.13%) ⬇️
relic_io 93.11% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

NormalizedPath drops the interned cache and hence the caching strategy
can no longer be set via the static NormalizedPath.interned property.

Cache (and LruCache/NoCache) stay for static_handler.
- fromUri reuses the unmodifiable Uri.pathSegments list when the path needs no
  normalization.
- Precompute each parameter Symbol at registration instead of per lookup.
- _lookupRecursive threads one mutable params map with undo on backtrack
  instead of copying it at each level.
PathTrie tracks needsBacktracking: true when a node has both literal children
and a dynamic segment. lookup uses the fast non-backtracking walk unless the
table needs backtracking; backtrack: false lets a literal shadow an overlapping
parameter. _lookupIterative is now a live default path.
@nielsenko
nielsenko force-pushed the refactor-no-normalized-path-interning branch from 4365a02 to c4cba4d Compare August 20, 2026 11:16
@nielsenko
nielsenko marked this pull request as ready for review August 21, 2026 16:32
@nielsenko
nielsenko requested a review from FXschwartz August 21, 2026 16:32
parameters[dynamicSegment.symbol] = segment;
final result = next(dynamicNode, newMap);
if (result != null) return result;
parameters.remove(dynamicSegment.symbol); // backtrack

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.

Looks like this drops the outer binding when the same param name is bound at two levels and the inner branch fails:

final trie = PathTrie<int>()
  ..add(NormalizedPath('/:x/lit/:x/a'), 1)
  ..add(NormalizedPath('/:x/:y/c'), 2);
trie.lookup(NormalizedPath('/v/lit/c')); // params = {y: lit} — x is gone

On main this returns {x: v, y: lit} since the per-level copy kept the outer value. Probably a good place to add a test as well.

}
currentNode.children.addAll(node.children);

_needsBacktracking =

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 ran into what looks like a stale-flag case with group(). Since attach(consume: false) shares the subtree, routes added to the sub router afterwards mutate the parent's nodes but only seem to set the sub trie's flag:

final sub = router.group('/api');
sub.get('/users/x', 1);
sub.get('/users/:id/y', 2);
router.lookup(Method.get, '/api/users/x/y'); // PathMiss — matches {id: x} on main

injectAt looks fine since it consumes. Maybe the flag needs to live with the shared structure rather than the PathTrie wrapper? Not sure what the cleanest fix is. Could use a regression test too, the backtrack tests cover the happy paths but not this one.

/// ```
/// A no-op [Cache] that never stores or retrieves values, for opting out of
/// caching in high-cardinality workloads where it costs more than it saves.
final class NoCache<K, V> implements Cache<K, V> {

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.

Is NoCache still needed? I couldn't find any consumers now that NormalizedPath.interned is gone, so there doesn't seem to be anything to plug it into.


group('Given NormalizedPath with NoCache', () {
late Cache<String, NormalizedPath> originalCache;
group('Given a Router (no result cache)', () {

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.

nit: this group tests Router.lookupUri rather than NoCache, maybe better off in a router test file?

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

Labels

None yet

Projects

None yet

2 participants