Skip to content

feat: replace routing_tree with an in-tree routing trie and support multiple listeners - #405

Open
Taure wants to merge 23 commits into
masterfrom
feat/routing-trie
Open

feat: replace routing_tree with an in-tree routing trie and support multiple listeners#405
Taure wants to merge 23 commits into
masterfrom
feat/routing-trie

Conversation

@Taure

@Taure Taure commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Finishes @burbas' work in #350, rebased onto current master. His commits are preserved in the history; #350 can be closed in favour of this.

Two goals, both from the 1.0 list: drop the external routing_tree dependency, and make Nova able to bind more than one port.

What was left to do

#350 compiles, but that is the only green light. It cannot boot: nova_router:routes/1 registers integer status-code routes for 404 and 500 on every startup, and the new trie had no clause for an integer path, so application:ensure_all_started(nova) died with a function_clause. It could not have served a request either: execute/2 and render_status_page/5 both call nova_routing_trie:find/4, which did not exist.

Behind that were the parity gaps: no [...] catch-all or pathinfo (so every static-file route was dead, including the one the rebar3_nova scaffold generates), no '_' any-method comparator (it normalised to <<"_">> while the sentinel was <<"ALL">>, making every websocket, static and methods => ['_'] route unmatchable), and no way to express 405 at all. insert/5 returned {ok, Trie} where the caller expected the tree, so any application with two routes failed to compile. compile/1 double-wrapped the options map, so use_strict_routing never reached the trie. And rebar.lock still pinned routing_tree 1.0.11, so the dependency was not actually gone.

Routing

nova_routing_trie now stands in for routing_tree. find/4 returns its shapes, including {error, comparator_not_found, AllowedMethods} for the 405 path. Integer status codes, the [...] catch-all and its pathinfo, and the '_' comparator all work. routes/1 is the supported way to introspect the table, since the trie itself is opaque.

Two matching improvements over routing_tree, both of which change which controller runs and are called out in guides/deprecations.md:

  • Matching backtracks. /a/:x/c now matches /a/b/c even when a /a/b/d route exists. Previously matching committed to the first sibling it found and returned a 404.
  • Every binding at a depth is reachable. /p/:id/picture and /p/:user_id/name both work. Previously only whichever one maps:fold visited first was, and which one that was depended on hash order.

Duplicate routes keep the first registration, as routing_tree did. add_routes/2 overwrites, which is what the routing guide has always promised.

Ambiguity detection only runs under use_strict_routing. As written it fired on /users/new alongside /users/:id, which would have warned on startup for most applications.

Router

Both find/4 call sites now call a function that exists, insert handles the real return shape, and lookup_url/4 passes arguments in the order the callee declares.

Two fixes worth singling out because they fail silently:

  • plugin_strategy deduped with lists:ukeysort(1, ...) on {Type, Module, Opts} tuples, which keys on the phase. At most one pre_request and one post_request plugin survived per route, including on the default path where the route declares no local plugins. A stock configuration silently lost JSON body decoding, CORS and correlation IDs. Dedupe is now on {Type, Module} and preserves order, and the default strategy reproduces existing behaviour.
  • remove_application/1 filtered on a 3-tuple while the trie emits 4-tuples, so it matched nothing and wiped the routing table for every application.

override_secure also accepted only a fun, so override_secure => true crashed compilation with a case_clause.

Multiple listeners

Each listener now owns its routing table, carried in its Cowboy env. Without that, all listeners shared one global nova_dispatch keyed only on host, path and method, so a second application on a second port made each listener answer for the other's routes: a second socket and nothing else.

The registry was created without a keypos, so every #nova_server{} row keyed on the record tag and it could hold exactly one entry — remove_application/1 answered {error, not_found} for every real application. add_application/2 on an already-bound port, the case guides/multi-app.md advertises, returned a bare ok into a case with no matching clause. Both are fixed, and removal now stops the listener and forgets its table only once nothing is left on it.

Graceful shutdown (#365) drains every listener. It suspended and stopped the single nova_listener atom, so any additional listener kept accepting connections through the drain and was killed outright at exit.

Testing

Nothing in this repo started Nova end to end, which is why a branch where the framework cannot boot survived for months. The only integration gate was nova_request_app, a separate repository that has to be updated by hand whenever Nova changes.

test/nova_test_app is a real Nova application — router, controllers, websocket controller, plugin, security callback, priv assets — with test/nova_test_sub_app mounted under a prefix. nova_full_app_SUITE drives it over a real Cowboy listener and nova_multi_listener_SUITE covers the lifecycle. Between them: bindings, precedence, every method, 405, redirects, status codes, host scoping, static directories, security and auth data, both plugin phases, custom error pages, crashes, websockets, sub-applications, runtime add and remove, and two listeners not serving each other's routes. Both run in CI on every OTP version.

Master's 25 test modules are ported off routing_tree, and nova_routing_trie_tests ports routing_tree's own suite as the parity checklist.

Two defects the new suites caught immediately

  • An application's own status-code route was unreachable. Nova compiles first and routes are first-wins, so a {404, fun my_controller:not_found/1, #{}} entry in a router was silently ignored. Nova is now compiled last, making its error pages the defaults they were meant to be.
  • resolve_nova_apps/2 (feat: support nested nova_apps #380) called lists:reverse/1 on an accumulator it was still accumulating, so as soon as any nova_app declared nova_apps of its own the resolution order came out scrambled. It also looked up nested apps with the whole {App, Options} tuple as the application name, so a sub-application in tuple form never had its own nova_apps resolved.

Not included

The #nova_handler_value.extra_stateextra rename is reverted for now — I asked on #350 whether it was deliberate. It is worth checking before it lands: nova_file_controller still reads extra_state in six places behind {status, 404} catch-alls, so the rename silently 404s every static route, and it also breaks the Req key that nova_json_schemas, egql_nova and nova_request_app read. Re-applying it is a one-line change plus lockstep updates downstream.

src/nova_request.erl, an unreferenced empty gen_server skeleton, is dropped. .github/labeler.yml is dropped too — the workflow that consumed it was deleted in #353.

Companion PR

rebar3_nova's routes, audit, openapi and doctor tasks all included routing_tree.hrl and destructured its records. novaframework/rebar3_nova#71 ports them to nova_routing_trie:routes/1 and needs to land with the Nova release.

Checks

rebar3 eunit 412/412, rebar3 ct 32/32, xref, dialyzer and ex_doc clean, elp lint clean. elp eqwalize-all is at 179 errors against master's 181 — nova_routing_trie is clean and nova_router is below where it started.

burbas and others added 23 commits September 27, 2025 21:24
…y server already runs on the configuration given
Brings the routing rewrite up to date with 34 commits of master.

Conflict resolution:
- rebar.config: master's dep set (cowboy 2.18.0, jhn_stdlib 5.11.2) minus
  routing_tree, master's test profile (proper/meck) and mutate plugin.
- nova_basic_handler: master's version. The branch had dropped render_dtl/3,
  which removes the code:load_file fallback that turns a missing template into
  a 404 rather than an undef/500, and master has since added CSRF injection.
- nova_router: keeps the branch's override_secure wrapper, master's binary
  ?LOG_DEPRECATED and permissive secure catch-all.
- nova_sup: reset to master. The branch's multi-listener rewrite has never
  executed (boot fails earlier) and reverts nested nova_apps, the session
  manager module_info detection and the deprecated ca_cert/cert TLS path.
  It is rebuilt on this baseline in a later commit.

Also:
- Drop routing_tree from nova.app.src applications and dialyzer plt_extra_apps,
  and relock. It was still pinned in rebar.lock, so the dependency was not
  actually removed yet; the relock also makes master's cowboy CVE bump real
  (cowlib 2.19.0, ranch 2.2.0).
- Revert #nova_handler_value.extra_state -> extra pending burbas' answer on
  nova#350. nova_file_controller still reads extra_state in six places behind
  {status, 404} catch-alls, so the rename silently 404s every static route.
- Drop .github/labeler.yml; the workflow that consumed it was deleted in #353.
- Drop src/nova_request.erl, an unreferenced empty gen_server skeleton.
- Add guides/multi-app.md to the ex_doc extras so it actually publishes.
The trie could not yet stand in for routing_tree. This closes the gaps and
freezes the contract nova_router is written against.

Added:
- find/4, the routing entry point, returning routing_tree's shapes:
  {ok, Bindings, Payload} | {ok, Bindings, Payload, PathInfo} |
  {error, not_found} | {error, comparator_not_found, AllowedMethods}.
  The last one is what Nova turns into a 405, and it had no way to say it.
- Integer paths for HTTP status codes. nova_router:routes/1 registers 404 and
  500 on every boot, so without this no Nova application starts at all.
- The [...] catch-all and its PathInfo, rejected when it is not the last
  segment. Every static-file route and the rebar3_nova scaffold needs it.
- '_' as the any-method comparator, matching what nova_router already passes.
  It previously normalised to <<"_">> while the sentinel was <<"ALL">>, so
  every websocket, static and methods => ['_'] route was unmatchable.
- routes/1 as the supported introspection API. The trie is opaque, so
  consumers need a stable shape rather than the internal map.
- on_duplicate, so add_routes/2 can overwrite as documented while compile
  keeps routing_tree's first-wins default.

Fixed:
- new/1 normalises its options. nova_router passed #{options => #{strict => X}}
  into a function that stored it verbatim, so use_strict_routing never reached
  the trie and strict mode was dead code.
- Matching backtracks past a literal that carries no payload, and tries every
  binding sibling rather than whichever one maps:fold visited first. With N
  bindings at one depth, N-1 were unreachable and which one survived depended
  on hash order.
- Ambiguity detection only runs under strict mode. It fired on /users/new
  alongside /users/:id, which is ordinary REST, and would have warned on
  startup for most applications.
- Non-strict inserts no longer walk the trie twice, once to detect conflicts
  and again to build.
- ".." is resolved and clamped at the root. The old check ate the root
  sentinel, so /../a became unroutable and logged a warning per request on
  attacker-controlled input.
- Query strings and fragments are stripped, and a pre-split segment list is
  accepted, both of which routing_tree handled.
- Conflicts log through the logger instead of io:format.

Removed the unused per-node plugins and options fields, the commented-out
method_member/2, and lookup/2,3,4, which returned an opaque node.

test/nova_routing_trie_tests.erl ports routing_tree's own suite as the parity
checklist and adds coverage for the behaviour that is deliberately different.
nova_router was still written against routing_tree with the module name
swapped, so nothing it did reached the new trie intact.

Contract:
- Both find/4 call sites (execute/2 and render_status_page/5) now call a
  function that exists. nova_routing_trie:find/4 was never implemented.
- insert/6 handles {ok, Trie} | {error, conflict, Conflict} instead of storing
  the return value as the dispatch table. Previously the first insert stored
  {ok, Trie} and the second badmatched, so any application with two routes
  failed to compile.
- lookup_url/4 passes arguments in the order the callee declares them, and
  lookup_url/1,2,3 keep their existing return shapes so nova_handler and
  downstream applications are unaffected.
- compile/1 no longer double-wraps the trie options, so use_strict_routing
  reaches the trie for the first time.

Plugins:
- plugin_strategy deduped with lists:ukeysort(1, ...) on {Type, Module, Opts}
  tuples, which keys on the phase. At most one pre_request and one
  post_request plugin survived per route, including on the default path where
  the route declares no local plugins at all - a stock configuration silently
  lost JSON body decoding, CORS and correlation IDs. Dedupe is now on
  {Type, Module} and preserves order.
- The default strategy is local_or_global, which is how Nova has always
  behaved. An unknown strategy logs and falls back rather than crashing the
  boot.

Security:
- override_secure accepted only a fun, so override_secure => true - the
  obvious reading of the name - crashed compilation with a case_clause. It now
  takes the same values as secure, and true is rejected with a log rather than
  a crash.

Runtime route changes:
- remove_application/1 filtered on a 3-tuple while the trie emits 4-tuples, so
  it matched nothing and wiped the routing table for every application. It now
  matches the real shape, keeps #cowboy_handler_value{} websocket routes in
  scope, honours dispatch_backend rather than hardcoding persistent_term, and
  drops the application from compiled_apps/0 and the nova env.
- add_routes/1 resolves the router the same way compile/3 does, so
  router_module and the Elixir App.Router convention are honoured, and it goes
  through get_routes/2 so controller-declared routes are picked up.
- add_routes/2 accepts a bare map and a flat list of route maps, not just a
  list of lists, and inserts with on_duplicate => overwrite so it behaves as
  the routing guide describes.

Also: compile/3 no longer lets one application's options leak into the next
one compiled, and no longer grows compiled_apps/0 and the nova apps env
without bound on every recompile. Dropped the dead canonicalise/2. The Allow
header is built with lists:join rather than percent-decoding method tokens.
Six modules only built an empty tree, so they are a module rename.
nova_router_tests also had to take the {ok, Trie} insert return and the
renamed lookup. 412 tests, 0 failures.
Nothing in this repo started Nova end to end, which is why a branch where the
framework cannot boot at all survived review. The only integration gate was
nova_request_app, a separate repository that has to be updated by hand
whenever Nova changes.

test/nova_test_app is a real Nova application - router, controllers,
websocket controller, plugin, security callback and priv assets - with
test/nova_test_sub_app mounted under a prefix through nova_apps. Between them
they exercise plain routes, single and multiple bindings, literal-beats-
binding precedence, every HTTP method, any-method routes, 405, redirects,
custom status codes, extra_state, host-scoped routes, [...] static
directories out of priv, security callbacks and auth_data, both plugin
phases, custom error pages, controller crashes, websockets, prefixed
sub-applications, and adding and removing an application at runtime.

nova_full_app_SUITE drives it over a real Cowboy listener on a free port with
httpc, plus a small hand-rolled websocket client so no client dependency is
needed. The test profile picks the applications up through project_app_dirs,
so none of it ships in the hex package.

Two defects it caught immediately:

- An application's own status-code route was unreachable. nova compiles first
  and routes are first-wins, so nova's default 404 and 500 always won and a
  {404, fun my_controller:not_found/1, #{}} entry in a router was silently
  ignored. nova is now compiled last, making its own error pages the defaults
  they were meant to be.

- resolve_nova_apps/2 (#380) called lists:reverse/1 on an accumulator that was
  still being accumulated, so as soon as any nova_app declared nova_apps of
  its own the resolution order came out scrambled - depth-first ordering the
  function documents was not what it produced. It also looked up nested apps
  with the whole {App, Options} tuple as the application name, so a
  sub-application declared in tuple form never had its own nova_apps resolved
  at all. Rewritten with the seen-set kept separate from the output.
The end-to-end suite is only a gate if CI runs it. Uploads the CT logs on
failure so a red run can be diagnosed without reproducing locally.
Nova bound exactly one listener. This finishes the runtime application
lifecycle the branch started, and makes multiple ports mean something.

Per-listener routing tables. Every listener carries the key of the routing
table it serves in its Cowboy env, and nova_router reads it from there.
Without this all listeners shared one global nova_dispatch keyed only on
host, path and method, so starting a second application on a second port made
each listener answer for the other's routes - a second socket and nothing
else. nova_router gains compile/2, add_routes/3, remove_application/2,
compiled_apps/1 and delete_dispatch/1; every existing arity keeps addressing
the default table, so nothing outside changes.

The listener registry. #nova_listener{} is keyed on the ranch ref with
{keypos, ...}, so the table holds one row per listener. It was created
without a keypos, which made every row key on the record tag: the registry
could only ever hold one entry, remove_application/1 answered {error,
not_found} for every real application, and get_started_applications/0 could
never list more than one. The table is public because the API is called by
whoever wants an application started, not by the supervisor process, and
creating it is idempotent so a nova_sup restart does not crash on the
existing name.

add_application/2 on an already-bound host and port now attaches the
application to that listener instead of returning a bare ok into a case with
no matching clause. That is the scenario guides/multi-app.md advertises, and
it crashed with {case_clause, ok}. Starting an application that is already
running is now {error, {already_started, App}} rather than a second bind
attempt.

remove_application/1 removes the application's routes, and stops the listener
and forgets its routing table only once no applications are left on it. It
previously stopped the listener and left the routes behind with a TODO.

The already-bound check compares the port a configuration actually binds. It
only ever read the port key, so a TLS listener on ssl_port was invisible to
it: a TLS application double-bound, and a plaintext application on that port
matched the TLS entry. The clear and TLS paths now share one bind and one
registration helper rather than registering in different orders.

Ranch refs are {nova_listener, App, Port}. Concatenating the application name
and port into an atom collided - foo1 on 8080 and foo on 18080 both produced
foo18080, so stopping one stopped the other - and grew the atom table on
every add and remove cycle. The bootstrap listener keeps the nova_listener
ref it has always had.

Graceful shutdown drains every listener. nova_app:prep_stop/1 suspended,
drained and stopped the single nova_listener atom, so any additional listener
kept accepting connections through the drain and was killed outright at exit.
Suspending and inspecting are now tolerant of a listener that has already
gone, so a runtime removal cannot fail the shutdown.

nova_multi_listener_SUITE covers all of it: two applications on two ports
serving only their own routes, attaching to a bound port, double-start,
teardown releasing the port, and removal leaving the other listener intact.
guides/routing.md gains the precedence rules, which are worth stating now
that they are deterministic: literal, then bindings in name order, then the
catch-all, with backtracking, and an exact method beating '_'. Also documents
plugin_strategy and override_secure, both of which the branch added with no
mention anywhere, and corrects add_route/2 to add_routes/2 - that function
has never existed under the documented name.

guides/multi-app.md is rewritten. It had two unterminated code fences that
swallowed the sys.config example, a table row with unescaped pipes, and it
described add_application/2 and remove_application/1 doing things the code
did not do. It now covers what actually happens, including that a listener on
a new port gets its own routing table and one on a bound port is shared.

guides/deprecations.md records the routing_tree removal and the behaviour
changes worth knowing about on upgrade: matching now backtracks, every
binding at a given depth is reachable, use_strict_routing takes effect for
the first time, and an application's own status-code routes now win over
Nova's defaults.

guides/graceful-shutdown.md no longer names a single listener, and
deprecations.md is added to the ex_doc extras so it publishes.

Also in this pass:
- dispatch_backend is narrowed to a module once rather than being called as
  whatever term the environment holds, which also takes nova_router below its
  previous eqwalizer error count.
- nova_routing_trie no longer treats unicode:characters_to_binary/1's error
  tuple as a binary, which would have put an unroutable route in the table.
  The module is eqwalizer-clean.
- inet:ntoa/1 is only called on an address tuple. ranch also accepts a
  hostname for ip, which would have crashed the startup log line.
- The CT suites export their cases explicitly instead of export_all, so elp
  lint stops reading their helpers as unreachable tests.
@Taure

Taure commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Both of the master-side defects this PR fixes are now split out into #406, so they can be reviewed and backported without the routing rewrite: resolve_nova_apps/2 returning applications in the wrong order, and an application's own status-code routes being unreachable.

This PR still contains both, as part of the nova_sup rewrite. If #406 lands first, this branch needs a git merge origin/master with its own nova_sup kept - it is a strict superset. Either merge order works.

@Taure

Taure commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Two things found while checking this branch against #408.

This closes #408

Verified against nova_routing_trie on this branch, using the declaration order from the nova-book my_first_nova example:

GET  /notes/new -> {ok,#{},new_form}
POST /notes/42  -> {ok,#{<<"id">> => <<"42">>},update}

On master the first of those returns {error,comparator_not_found,[<<"POST">>]}, which is the 405 the reporter sees. The cause is routing_tree:lookup_segment/4: it returns on the first sibling that matches, and a binding node matches any segment. Wildcards are carried as a deferred fallback, bindings never were, so there was no literal-beats-binding precedence at all. Since insert/4 prepends, sibling order is declaration order reversed, which is why the route declared first is the one that loses.

The trie here gets it right in both declaration orders, when both routes share a method, and for the backtracking and sibling-binding cases the description claims. Worth linking so #408 closes on merge.

use_strict_routing was not a no-op

guides/deprecations.md says:

use_strict_routing now takes effect. It never reached the routing table before, so it has been a no-op.

It did reach it. nova_router:compile/1 on master passes #{use_strict => UseStrict} into routing_tree:new/1, and routing_tree honours it. Against the released 1.0.11:

same method: /notes/new GET + /notes/:id GET   -> throws non_deterministic_paths
reverse:     /notes/:id GET + /notes/new GET   -> throws non_deterministic_paths
true dup:    /notes/new GET twice              -> throws duplicated_paths
diff method: /notes/new GET + /notes/:id POST  -> no throw
deeper:      /a/b/c GET + /a/:x/c GET          -> no throw

So it works, with holes: it only checks terminal segments and only when the comparators match. nova_request_app runs with it enabled in its prod config, and it is used the same way in private downstream applications, so this is live behaviour rather than a dormant flag. The entry should say it was inconsistent, not dead.

The overshadowing_route conflict is now a false positive

Under strict the trie rejects this:

insert /notes/:id -> {error,conflict,#{reason => overshadowing_route,
                                       conflicts_with => <<"/notes/new">>,
                                       incoming => {binding,<<"id">>}, ...}}

That is the pair guides/routing.md tells people to write two paragraphs earlier:

/users/new and /users/:id can therefore both be declared: /users/new serves the literal path and /users/:id serves everything else.

Both statements are in this PR. Before the rewrite the overlap was genuinely undecidable, so flagging it was reasonable. Now precedence is specified, matching backtracks, and the overlap resolves deterministically, so there is nothing left to warn about. Anyone who turns strict routing on is unable to follow the tutorial.

The other half of the check is still worth keeping. A duplicate path and method is silently first-wins, which is exactly the silent route drop the flag was turned on to catch, and that reason survives the rewrite intact.

Suggest narrowing strict to duplicates and genuine ambiguity (binding vs wildcard at the same depth), and dropping overshadowing_route. That resolves the contradiction with the routing guide without giving up the guard.

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