fix(router): share route syntax across routing operations - #3133
Conversation
vishr
left a comment
There was a problem hiding this comment.
Review at 20c64cb. The three issues left after #3113 are fixed: /:name\:verb registers parameter name plus a literal :verb, Remove finds escaped-colon routes (and no longer deletes an unrelated entry when the lookup misses), and Reverse keeps the verb (/a/:x\:v/:y with "1","2" gives /a/1:v/2). CI is green.
The new colon split in Find introduces regressions that need fixing before merge. Each was reproduced against this head and master.
Must fix
-
The colon split cannot be backtracked (router.go#L1026). Once the split is chosen, a failed match never retries with the whole segment as the parameter value.
- GET
/r/:id/info+ GET/r/:name\:ab:p/z:GET /r/q:abc/inforeturns 404 here, 200 withid=q:abcon master. - Same with
/r/:name\:y/:p/zandGET /r/q:y/info.
- GET
-
canMatchStaticSuffixignores the request method (router.go#L811). It accepts anyisHandlernode, so a verb route for another method hides a generic parameter route that allows this method.- GET
/r/:id+ POST/r/:name\:cancel:GET /r/foo:cancelreturns 405 here, 200 on master. - Echo otherwise falls back from static to parameter on a method mismatch (POST
/r/foo+ GET/r/:id, then GET/r/foois 200).
- GET
-
An empty parameter value is accepted (router.go#L1027). The split loop starts at 0, so GET
/r/:name\:cancel+GET /r/:cancelreturns 200 withname="". Starting at 1 keeps the usual non-empty parameter rule.
Findings 1 and 2 share a cause: canMatchStaticSuffix (router.go#L805) is a second, simplified matcher that guesses whether the rest of the path will match. Suggest dropping it and recording the split as a backtrack point in the main loop: try the split, and on failure retry with the value running to the next /.
Should fix
- Find cost on parameter nodes (router.go#L1026). Every parameter node with children now does a linear
findStaticChild(':')scan. The benchmark job showsRouterParamsAndAnyAPI1.984µs to 2.119µs (+6.8%, p=0.000), paid by apps that never register an inline verb. AcolonChild *node(or a flag) kept in sync by insert and Remove would make it O(1). - Two tree-path builders (router.go#L549).
Addbuilds the tree path, markers and names inline, whileRemoveusesrouteTreePath. Use the helper in both, so the two cannot drift apart again. Reverseallocates per call (route.go#L85). It now runsparseRoutePathon every call. Parse once atAddtime, or keep a single pass over the path.
Notes
- Behavior change for release notes (route_path.go#L32).
/x/:id\:yused to register one parameter namedid\:y; it now registersidplus a literal:y.c.ParamandRouteInfo.Parameterschange for anyone relying on the old form. - Sibling
/:namestops matching slashes once a verb route is registered next to it, because the parameter node is no longer a leaf. This matches what adding/r/:name/editalready does, so worth documenting rather than changing. - Pre-existing, not from this PR:
Removestill compares against the node's singleoriginalPath. GET/u/:id+ POST/u/:uid:Remove("GET", "/u/:id")fails, whileRemove("GET", "/u/:uid")removes the GET route. Could be a follow-up.
Regression tests for 1–3 would keep these fixed.
vishr
left a comment
There was a problem hiding this comment.
Re-review at 9b86d77. All seven findings from the previous review are fixed: split backtracking, method-aware fallback, empty values, the reported static-priority case, the Find slowdown (RouterParamsAndAnyAPI now within noise of master, 0 allocs), one routeTreePath for Add/Remove, and Reverse allocations. Remove then re-add keeps hasInlineVerb correct, and go test -race . passes.
The new retry design brings its own problems. Each item below was reproduced against this head.
Must fix
- CPU denial of service (router.go#L1041). Every colon in the segment adds a full re-search pass, and each pass rescans the segment, so cost grows with the square of the colon count. Chained inline params multiply it. Only one inline-verb route needs to be registered.
- GET
/r/:name\:cancel, request/r/a+ 40,000:: 1.3 s, then 404. With 100,000 colons (about 100 KB, under the default 1 MB header limit): 8.2 s. - GET
/:a\::b\::c/end, request/x+ 1000 ×:x+/nope(about 2 KB): 1.95 s.
- GET
- Group middleware or a catch-all route disables the fallback (router.go#L1109). The unsplit retry only runs after a whole pass fails. If backtracking in that pass reaches an any-route or
RouteNotFoundnode, that counts as the match and the generic route is never tried.- Group
/rwith middleware, routes/:nameand/:name\:cancel:GET /r/foo:otherreturns 404. - Routes
/r/:name,/r/:name\:canceland/*:GET /r/foo:othergoes to/*. - Most real apps have one of these, so the basic verb-plus-generic case breaks for them.
- Group
- A trailing leaf param stops at
/after any split (router.go#L1026).splitUsedcovers the whole pass.- GET
/r/:name\:x/:rest:GET /r/a:x/b/creturns 404. The plain/r/:name/x/:restgivesrest=b/c.
- GET
- Priority across branches (router.go#L1113, #L1041). Split choices are enumerated globally, and the leftmost colon is always tried first.
/r/:id/info,/r/:name\:zz/q,/:p/:name\:ab/info:GET /r/q:ab/infogoes to the root param route, although staticr/outranks:p./r/:name\::verb+/r/:name\:cancel:GET /r/x:y:cancelgivesname=x,verb=y:cancelinstead of the staticcancelroute./r/:name\:x*+/r/:id/info:GET /r/a:x/infogoes to the any-verb route, while the analogous:paramcase resolves to the static route.
Root cause and suggestion
Findings 2–4 come from restarting the whole search with a global split plan (splitPlan/splitOptions/splitUsed) instead of treating the split as a backtrack point on the param node. The global plan loses the static > param > any order of ancestor nodes and the per-node leaf rule. Suggestion:
- When backtracking out of a param node with
hasColonChild, re-enter the same param with the next split position (store the split index next to its path value) before going up to the parent. - Only count a split where the colon child's prefix actually matches (
strings.HasPrefix(search[split:], child.prefix)). That bounds the work and also fixes finding 1.
Alternative worth considering: limit inline verbs to the last segment of a route (/:name\:verb, nothing after). Google-style custom methods are always terminal, so #3111 is covered, and matching becomes a single check of the final segment against the colon child with no retries.
Should fix
- Allow header on 405 (router.go#L1109). Only the first failing pass's node is kept. POST
/r/:name\:cancel+ PUT/r/:id:GET /r/foo:cancelreturns 405 withAllow: OPTIONS, POST, but PUT works. - Two copies of
Route(router_plain.go#L14). The ~270-line matcher is duplicated inrouter_plain.goandrouteInline, differing only in the split block and retry loop. A singleRoutewith the split block gated oncurrentNode.hasColonChildshould keep the no-verb path the same cost. - Allocations and redundant state (router.go#L481, #L1110).
Removere-parses every route to recomputehasInlineVerb(O(N²) over N removals).hasColonChildduplicatesbytes.IndexByte(n.scLabels, ':') >= 0. The retry path allocates 2 per request (fallbackValues,splitPlan).
Replace the whole-search retry plan with a backtrack point on the param node: when a candidate split fails, the same param node is retried with the next literal-colon split and finally the whole path segment before routing backtracks to its parent. Static > param > any priority of ancestors, the leaf rule of later params, group middleware and catch-all routes are no longer affected by a failed split. An escaped colon after a parameter starts an inline verb only when the rest of that path segment is static (`/:name\:cancel`, optionally followed by `/...`). Split candidates are then scanned once per segment, so a request with many colons is routed in linear time. Other escaped colons after a parameter keep their older meaning as part of the parameter name. The duplicated fast-path Route and the router-level inline verb flag are removed. The param scan uses strings.IndexByte, which keeps router benchmarks within about 1% of master (geomean).
A wildcard ends the route search, but a param value above it that ended at an inline verb split is now retried with the next split and the whole segment, so a verb route with a wildcard cannot shadow a generic route for other methods. The retry now runs only when routing backtracks from the inline verb child into its param node, instead of on every dead end. An escaped colon after a parameter keeps its older meaning (part of the parameter name) unless the first one in the segment starts an inline verb, so a later `\:` cannot turn such a legacy route into a verb route. Reverse writes placeholders for such names without the backslash, as before. Dead hasColonChild bookkeeping for split nodes is removed.
When a wildcard fails below a param value that ended at an inline verb split, keep backtracking one node at a time instead of jumping to that param node, so the other routes below the split are tried before the next split. Without a pending split a failed wildcard still ends the search as before, and the check does not change the routing state. Adds tests for nested splits, routes below a split and a RouteNotFound wildcard below a split.
Remove found a node by the last route that set its original path and then removed whichever route owned the method there. Routes with different param names share a node, so Remove now checks that the path is the one registered for that method. An escaped colon after a param name that contains ':' or '*' keeps its older meaning as part of the name. The escaped colon check is shared in the route syntax scanner. Adds tests for RouteNotFound on the whole segment, encoded colons, Reverse placeholders and Remove.
Registering an inline verb route under a param gave the param node a static child, so a sibling route ending in that param (`/files/:path`) stopped matching values across slashes. When the inline verb child is the node's only child, a param value without a split now takes the rest of the path, as a leaf param does.
Drop the param and any child checks that cannot fail for a param node, and pin 405, RouteNotFound and wildcard fallbacks for a param value that spans slashes next to an inline verb route.
Summary
Follow-up to #3113. Three route-syntax bugs remained after it: a literal
\:after a parameter was swallowed into the parameter name,Removecould not find escaped-colon routes, and reverse routing dropped the literal verb./:name\:cancel) when the rest of that path segment is static. Other escaped colons after a parameter keep their older meaning as part of the parameter name.GET /files/:pathnext toPOST /files/:name\:uploadstill matches/files/a/b.Compatibility notes (release notes)
/x/:id\:ynow registers parameteridand a literal:y. Before, it registered one parameter namedid\:ythat matched any segment, soRouteInfo.Parameters,c.Paramlookups and matching change for apps that relied on that.Removereturns an error for a path that only shares a tree node with the route registered for that method (for example/u/:uidwhen GET was registered as/u/:id), instead of removing that route.Performance
Router benchmarks against master, 8 interleaved runs on Apple M3 Max: geomean +0.8%, 0 allocs/op everywhere. The param scan now uses
strings.IndexByte, which pays for the extra checks.Verification
go test ./... -count=1,go test -race .,go vet ./...,staticcheck ./...Refs #3111. The v4 backport is in #3132.