From 58d8c94ea75513200c973e5a9fc14b03109728d1 Mon Sep 17 00:00:00 2001 From: Relism Date: Sun, 15 Mar 2026 01:15:43 +0100 Subject: [PATCH] initial ? wtf --- .gitignore | 18 + .idea/.gitignore | 8 + .idea/copilot.data.migration.agent.xml | 6 + .idea/copilot.data.migration.ask.xml | 6 + .idea/copilot.data.migration.ask2agent.xml | 6 + .idea/copilot.data.migration.edit.xml | 6 + .idea/encodings.xml | 13 + .idea/misc.xml | 14 + .idea/vcs.xml | 6 + LICENSE | 21 + README.md | 49 ++ docs/ARCHITECTURE.md | 23 + fpr-bench/pom.xml | 137 +++++ .../dev/relism/fpr/bench/RouterBench.java | 79 +++ .../relism/fpr/bench/RouterBenchLatency.java | 83 +++ .../relism/fpr/bench/RouterBenchState.java | 278 +++++++++ .../fpr/bench/RouterBenchThroughput.java | 83 +++ fpr-bench/tools/README.md | 55 ++ fpr-bench/tools/fpr_bench.py | 536 +++++++++++++++++ fpr-core/pom.xml | 48 ++ .../java/dev/relism/fpr/core/ByteView.java | 15 + .../dev/relism/fpr/core/FastPathRouter.java | 10 + .../java/dev/relism/fpr/core/MatchResult.java | 211 +++++++ .../relism/fpr/core/MatchResultAccess.java | 77 +++ .../dev/relism/fpr/core/ParamConsumer.java | 9 + .../dev/relism/fpr/core/RoutePattern.java | 153 +++++ .../dev/relism/fpr/core/RouterBuilder.java | 144 +++++ .../fpr/core/dsl/StringRouteParser.java | 130 ++++ .../core/internal/compile/FreezeWriter.java | 254 ++++++++ .../core/internal/compile/IndexBuilder.java | 102 ++++ .../internal/compile/PrimitiveBuilders.java | 204 +++++++ .../core/internal/compile/RouteCompiler.java | 70 +++ .../fpr/core/internal/compile/RouteGraph.java | 558 ++++++++++++++++++ .../lookup/LiteralLookupPlanBuilder.java | 86 +++ .../lookup/MixedLookupPlanBuilder.java | 18 + .../core/internal/runtime/ByteCompare.java | 53 ++ .../core/internal/runtime/EdgeDispatch.java | 35 ++ .../fpr/core/internal/runtime/EdgeKind.java | 9 + .../core/internal/runtime/FrozenRouter.java | 250 ++++++++ .../core/internal/runtime/RouteSearch.java | 245 ++++++++ .../core/internal/runtime/SegmentCursor.java | 64 ++ .../runtime/lookup/LiteralLookup.java | 160 +++++ .../runtime/lookup/LiteralLookupStrategy.java | 14 + .../internal/runtime/lookup/MixedLookup.java | 102 ++++ .../runtime/lookup/MixedLookupStrategy.java | 14 + .../fpr/core/RouterConcurrencyTest.java | 145 +++++ .../dev/relism/fpr/core/RouterMatchTest.java | 277 +++++++++ fpr-netty/pom.xml | 72 +++ .../relism/fpr/netty/NettyByteBufView.java | 56 ++ .../relism/fpr/netty/NettyPathExtractor.java | 64 ++ .../relism/fpr/netty/NettyAdaptersTest.java | 63 ++ pom.xml | 121 ++++ 52 files changed, 5260 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/copilot.data.migration.agent.xml create mode 100644 .idea/copilot.data.migration.ask.xml create mode 100644 .idea/copilot.data.migration.ask2agent.xml create mode 100644 .idea/copilot.data.migration.edit.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/vcs.xml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 fpr-bench/pom.xml create mode 100644 fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBench.java create mode 100644 fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchLatency.java create mode 100644 fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchState.java create mode 100644 fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchThroughput.java create mode 100644 fpr-bench/tools/README.md create mode 100644 fpr-bench/tools/fpr_bench.py create mode 100644 fpr-core/pom.xml create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/ByteView.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/FastPathRouter.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/MatchResult.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/MatchResultAccess.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/ParamConsumer.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/RoutePattern.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/RouterBuilder.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/dsl/StringRouteParser.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/FreezeWriter.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/IndexBuilder.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/PrimitiveBuilders.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/RouteCompiler.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/RouteGraph.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/lookup/LiteralLookupPlanBuilder.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/lookup/MixedLookupPlanBuilder.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/ByteCompare.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/EdgeDispatch.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/EdgeKind.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/FrozenRouter.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/RouteSearch.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/SegmentCursor.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/LiteralLookup.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/LiteralLookupStrategy.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/MixedLookup.java create mode 100644 fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/MixedLookupStrategy.java create mode 100644 fpr-core/src/test/java/dev/relism/fpr/core/RouterConcurrencyTest.java create mode 100644 fpr-core/src/test/java/dev/relism/fpr/core/RouterMatchTest.java create mode 100644 fpr-netty/pom.xml create mode 100644 fpr-netty/src/main/java/dev/relism/fpr/netty/NettyByteBufView.java create mode 100644 fpr-netty/src/main/java/dev/relism/fpr/netty/NettyPathExtractor.java create mode 100644 fpr-netty/src/test/java/dev/relism/fpr/netty/NettyAdaptersTest.java create mode 100644 pom.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f04659 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Maven +/target/ +**/target/ +*.log + +# IntelliJ IDEA +.idea/ +*.iml + +# VS Code +.vscode/ + +# OS +.DS_Store +Thumbs.db + +# Bench results +fpr-bench/results/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..ef15274 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..5f3e0f8 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eb30a4b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Relism + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..384da60 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# FastPathRouter (FPR) + +FastPathRouter is a tiny, GC-friendly routing core designed to compile startup-time route definitions into immutable, array-based match tables. The hot path avoids String and Map allocations and works directly on byte views. + +Modules: +- fpr-core: routing core and compiler +- fpr-netty: Netty adapters (ByteBuf view + path extraction) +- fpr-bench: JMH benchmarks + +Philosophy: +- build routes at startup, compile into frozen tables +- match using byte-level comparisons +- avoid String/Map allocations in the hot path + +Route syntax: +- params use `{name}` (e.g. `/users/{id}`) +- mixed segments are allowed (e.g. `/static/pre-{x}-suf`) +- `*` matches a single segment, `**` matches the rest of the path + +Zero-copy param extraction: +```java +RouterBuilder builder = new RouterBuilder<>(); +builder.add(StringRouteParser.parse("/users/{id}/orders/{orderId}"), "H"); +FastPathRouter router = builder.compile(); +String[] paramNames = builder.paramNames(); + +ByteView view = ...; +MatchResult out = new MatchResult<>(builder.maxParamCount()); +router.match(view, out); +out.forEachParam(view, paramNames, (name, bytes, start, len) -> { + // Use bytes + start/len as needed; convert only if required. +}); +``` + +Build and test: +``` +mvn -q -DskipTests=false test +``` + +Run benchmarks: +``` +mvn -pl fpr-bench -DskipTests package +java -jar fpr-bench/target/fpr-bench-1.0-SNAPSHOT-shaded.jar -wi 5 -i 5 +``` + +Or use the Maven exec profile: +``` +mvn -pl fpr-bench -am -Pbench verify +``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c0435b5 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,23 @@ +# Architecture + +FastPathRouter is split into a small public API and an internal compiler/matcher pipeline. + +1) Builder +Routes are added to `RouterBuilder` using structured `RoutePattern` segments. The builder assigns parameter key ids and collects handler bindings. + +2) Compile / freeze +Routes are compiled into a graph and then frozen into immutable arrays. The compile pipeline lives in `dev.relism.fpr.core.internal.compile`: +- `RouteCompiler` orchestrates the process. +- `RouteGraph` is the intermediate state/edge graph. +- `FreezeWriter` emits primitive arrays and the blob. +- `IndexBuilder` builds per-state first-byte indexes. + +3) Match +Matching walks the input path by segments, compares bytes, and records param spans in a reusable `MatchResult` container. Runtime pieces live in `dev.relism.fpr.core.internal.runtime`: +- `FrozenRouter` is the match loop and state transitions. +- `EdgeDispatch` selects candidate edges for a state. +- `SegmentMatcher` evaluates a single segment (literal/mixed/param/wild). +- `ByteCompare` performs bulk byte comparisons. + +No Strings or Maps are created during matching. +If you need params as name/value pairs, `MatchResult.forEachParam` can stream spans via `ParamConsumer` without allocations. diff --git a/fpr-bench/pom.xml b/fpr-bench/pom.xml new file mode 100644 index 0000000..b651101 --- /dev/null +++ b/fpr-bench/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + + dev.relism + fastpathrouter-parent + 1.0-SNAPSHOT + + + fpr-bench + jar + + + + + io.netty + netty-bom + ${netty.version} + pom + import + + + + + + + dev.relism + fpr-core + + + dev.relism + fpr-netty + + + org.openjdk.jmh + jmh-core + + + org.openjdk.jmh + jmh-generator-annprocess + provided + + + io.netty + netty-buffer + + + org.projectlombok + lombok + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + + org.openjdk.jmh.Main + + + + + + + + + + + + bench + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + run-bench + verify + + exec + + + java + + -jar + ${project.build.directory}/${project.build.finalName}-shaded.jar + -wi + 1 + -i + 1 + -f + 1 + + + + + + + + + + diff --git a/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBench.java b/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBench.java new file mode 100644 index 0000000..9816f1d --- /dev/null +++ b/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBench.java @@ -0,0 +1,79 @@ +package dev.relism.fpr.bench; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.infra.Blackhole; + +@BenchmarkMode({Mode.Throughput, Mode.SampleTime}) +public class RouterBench { + @Benchmark + public int matchArrayLiteral(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayLiteral(blackhole); + } + + @Benchmark + public int matchArrayParam(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayParam(blackhole); + } + + @Benchmark + public int matchArrayMixed(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayMixed(blackhole); + } + + @Benchmark + public int matchArrayCatchAll(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayCatchAll(blackhole); + } + + @Benchmark + public int matchNettyLiteral(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyLiteral(blackhole); + } + + @Benchmark + public int matchNettyParam(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyParam(blackhole); + } + + @Benchmark + public int matchNettyMixed(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyMixed(blackhole); + } + + @Benchmark + public int matchNettyCatchAll(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyCatchAll(blackhole); + } + + @Benchmark + public int matchLiteralHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchLiteralHeavy(blackhole); + } + + @Benchmark + public int matchParamHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchParamHeavy(blackhole); + } + + @Benchmark + public int matchMixedHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchMixedHeavy(blackhole); + } + + @Benchmark + public int matchCatchAllHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchCatchAllHeavy(blackhole); + } + + @Benchmark + public int matchLargeRouteSet(RouterBenchState state, Blackhole blackhole) { + return state.matchLargeRouteSet(blackhole); + } + + @Benchmark + public int matchVariety(RouterBenchState state, Blackhole blackhole) { + return state.matchVariety(blackhole); + } +} diff --git a/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchLatency.java b/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchLatency.java new file mode 100644 index 0000000..f8c0ecc --- /dev/null +++ b/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchLatency.java @@ -0,0 +1,83 @@ +package dev.relism.fpr.bench; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.concurrent.TimeUnit; + +@BenchmarkMode(Mode.SampleTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +public class RouterBenchLatency { + @Benchmark + public int matchArrayLiteral(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayLiteral(blackhole); + } + + @Benchmark + public int matchArrayParam(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayParam(blackhole); + } + + @Benchmark + public int matchArrayMixed(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayMixed(blackhole); + } + + @Benchmark + public int matchArrayCatchAll(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayCatchAll(blackhole); + } + + @Benchmark + public int matchNettyLiteral(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyLiteral(blackhole); + } + + @Benchmark + public int matchNettyParam(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyParam(blackhole); + } + + @Benchmark + public int matchNettyMixed(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyMixed(blackhole); + } + + @Benchmark + public int matchNettyCatchAll(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyCatchAll(blackhole); + } + + @Benchmark + public int matchLiteralHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchLiteralHeavy(blackhole); + } + + @Benchmark + public int matchParamHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchParamHeavy(blackhole); + } + + @Benchmark + public int matchMixedHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchMixedHeavy(blackhole); + } + + @Benchmark + public int matchCatchAllHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchCatchAllHeavy(blackhole); + } + + @Benchmark + public int matchLargeRouteSet(RouterBenchState state, Blackhole blackhole) { + return state.matchLargeRouteSet(blackhole); + } + + @Benchmark + public int matchVariety(RouterBenchState state, Blackhole blackhole) { + return state.matchVariety(blackhole); + } +} diff --git a/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchState.java b/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchState.java new file mode 100644 index 0000000..1133b51 --- /dev/null +++ b/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchState.java @@ -0,0 +1,278 @@ +package dev.relism.fpr.bench; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.FastPathRouter; +import dev.relism.fpr.core.MatchResult; +import dev.relism.fpr.core.RouterBuilder; +import dev.relism.fpr.core.dsl.StringRouteParser; +import dev.relism.fpr.netty.NettyByteBufView; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.infra.Blackhole; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.nio.charset.StandardCharsets; + +@State(Scope.Benchmark) +public class RouterBenchState { + private FastPathRouter routerSmall; + private FastPathRouter routerLiteralHeavy; + private FastPathRouter routerParamHeavy; + private FastPathRouter routerMixedHeavy; + private FastPathRouter routerCatchAll; + private FastPathRouter routerLarge; + + private MatchResult outSmall; + private MatchResult outLarge; + + private ByteView arrayLiteral; + private ByteView arrayParam; + private ByteView arrayMixed; + private ByteView arrayCatchAll; + private NettyByteBufView nettyLiteral; + private NettyByteBufView nettyParam; + private NettyByteBufView nettyMixed; + private NettyByteBufView nettyCatchAll; + + private ByteView literalHeavyPath; + private ByteView paramHeavyPath; + private ByteView mixedHeavyPath; + private ByteView catchAllPath; + private ByteView largePath; + + private ByteView[] varietyPaths; + private int varietyIndex; + + private ByteBuf nettyBufLiteral; + private ByteBuf nettyBufParam; + private ByteBuf nettyBufMixed; + private ByteBuf nettyBufCatchAll; + + @Setup(Level.Trial) + public void setup() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/users"), "A"); + builder.add(StringRouteParser.parse("/users/{id}"), "B"); + builder.add(StringRouteParser.parse("/users/{id}/orders/{orderId}"), "C"); + builder.add(StringRouteParser.parse("/assets/**"), "D"); + builder.add(StringRouteParser.parse("/static/pre-{x}-suf"), "E"); + routerSmall = builder.compile(); + outSmall = new MatchResult<>(builder.maxParamCount(), 64); + + byte[] literal = "/users".getBytes(StandardCharsets.US_ASCII); + byte[] param = "/users/123".getBytes(StandardCharsets.US_ASCII); + byte[] mixed = "/static/pre-xyz-suf".getBytes(StandardCharsets.US_ASCII); + byte[] catchAll = "/assets/css/app.css".getBytes(StandardCharsets.US_ASCII); + + arrayLiteral = new ByteArrayView(literal); + arrayParam = new ByteArrayView(param); + arrayMixed = new ByteArrayView(mixed); + arrayCatchAll = new ByteArrayView(catchAll); + + nettyBufLiteral = Unpooled.wrappedBuffer(literal); + nettyBufParam = Unpooled.wrappedBuffer(param); + nettyBufMixed = Unpooled.wrappedBuffer(mixed); + nettyBufCatchAll = Unpooled.wrappedBuffer(catchAll); + + nettyLiteral = new NettyByteBufView(nettyBufLiteral, 0, nettyBufLiteral.readableBytes()); + nettyParam = new NettyByteBufView(nettyBufParam, 0, nettyBufParam.readableBytes()); + nettyMixed = new NettyByteBufView(nettyBufMixed, 0, nettyBufMixed.readableBytes()); + nettyCatchAll = new NettyByteBufView(nettyBufCatchAll, 0, nettyBufCatchAll.readableBytes()); + + routerLiteralHeavy = buildLiteralHeavy(); + routerParamHeavy = buildParamHeavy(); + routerMixedHeavy = buildMixedHeavy(); + routerCatchAll = buildCatchAll(); + routerLarge = buildLarge(); + outLarge = new MatchResult<>(4, 128); + + literalHeavyPath = new ByteArrayView("/route-199".getBytes(StandardCharsets.US_ASCII)); + paramHeavyPath = new ByteArrayView("/p199/alpha".getBytes(StandardCharsets.US_ASCII)); + mixedHeavyPath = new ByteArrayView("/m199/pre-xyz-suf".getBytes(StandardCharsets.US_ASCII)); + catchAllPath = new ByteArrayView("/assets/dir/file.js".getBytes(StandardCharsets.US_ASCII)); + largePath = new ByteArrayView("/r9999".getBytes(StandardCharsets.US_ASCII)); + + varietyPaths = new ByteView[]{ + new ByteArrayView("/users".getBytes(StandardCharsets.US_ASCII)), + new ByteArrayView("/users/7".getBytes(StandardCharsets.US_ASCII)), + new ByteArrayView("/static/pre-foo-suf".getBytes(StandardCharsets.US_ASCII)), + new ByteArrayView("/assets/img/logo.png".getBytes(StandardCharsets.US_ASCII)), + new ByteArrayView("/users/9/orders/3".getBytes(StandardCharsets.US_ASCII)) + }; + } + + public int matchArrayLiteral(Blackhole blackhole) { + outSmall.reset(); + int id = routerSmall.match(arrayLiteral, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchArrayParam(Blackhole blackhole) { + outSmall.reset(); + int id = routerSmall.match(arrayParam, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchArrayMixed(Blackhole blackhole) { + outSmall.reset(); + int id = routerSmall.match(arrayMixed, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchArrayCatchAll(Blackhole blackhole) { + outSmall.reset(); + int id = routerSmall.match(arrayCatchAll, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchNettyLiteral(Blackhole blackhole) { + outSmall.reset(); + int id = routerSmall.match(nettyLiteral, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchNettyParam(Blackhole blackhole) { + outSmall.reset(); + int id = routerSmall.match(nettyParam, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchNettyMixed(Blackhole blackhole) { + outSmall.reset(); + int id = routerSmall.match(nettyMixed, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchNettyCatchAll(Blackhole blackhole) { + outSmall.reset(); + int id = routerSmall.match(nettyCatchAll, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchLiteralHeavy(Blackhole blackhole) { + outSmall.reset(); + int id = routerLiteralHeavy.match(literalHeavyPath, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchParamHeavy(Blackhole blackhole) { + outSmall.reset(); + int id = routerParamHeavy.match(paramHeavyPath, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchMixedHeavy(Blackhole blackhole) { + outSmall.reset(); + int id = routerMixedHeavy.match(mixedHeavyPath, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchCatchAllHeavy(Blackhole blackhole) { + outSmall.reset(); + int id = routerCatchAll.match(catchAllPath, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + public int matchLargeRouteSet(Blackhole blackhole) { + outLarge.reset(); + int id = routerLarge.match(largePath, outLarge); + blackhole.consume(outLarge.handler()); + return id; + } + + public int matchVariety(Blackhole blackhole) { + if (varietyIndex >= varietyPaths.length) { + varietyIndex = 0; + } + ByteView view = varietyPaths[varietyIndex++]; + outSmall.reset(); + int id = routerSmall.match(view, outSmall); + blackhole.consume(outSmall.handler()); + return id; + } + + private FastPathRouter buildLiteralHeavy() { + RouterBuilder builder = new RouterBuilder<>(); + for (int i = 0; i < 200; i++) { + builder.add(StringRouteParser.parse("/route-" + i), "L" + i); + } + return builder.compile(); + } + + private FastPathRouter buildParamHeavy() { + RouterBuilder builder = new RouterBuilder<>(); + for (int i = 0; i < 200; i++) { + builder.add(StringRouteParser.parse("/p" + i + "/{id}"), "P" + i); + } + return builder.compile(); + } + + private FastPathRouter buildMixedHeavy() { + RouterBuilder builder = new RouterBuilder<>(); + for (int i = 0; i < 200; i++) { + builder.add(StringRouteParser.parse("/m" + i + "/pre-{x}-suf"), "M" + i); + } + return builder.compile(); + } + + private FastPathRouter buildCatchAll() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/assets/**"), "CATCH"); + builder.add(StringRouteParser.parse("/assets/images/**"), "CATCH2"); + return builder.compile(); + } + + private FastPathRouter buildLarge() { + RouterBuilder builder = new RouterBuilder<>(); + for (int i = 0; i < 10_000; i++) { + builder.add(StringRouteParser.parse("/r" + i), "R" + i); + } + return builder.compile(); + } + + private static final class ByteArrayView implements ByteView { + private static final VarHandle LONG_VIEW = MethodHandles.byteArrayViewVarHandle(long[].class, java.nio.ByteOrder.LITTLE_ENDIAN); + private final byte[] bytes; + + private ByteArrayView(byte[] bytes) { + this.bytes = bytes; + } + + @Override + public int length() { + return bytes.length; + } + + @Override + public byte byteAt(int index) { + return bytes[index]; + } + + @Override + public boolean supportsLong() { + return true; + } + + @Override + public long longAt(int index) { + return (long) LONG_VIEW.get(bytes, index); + } + } +} diff --git a/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchThroughput.java b/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchThroughput.java new file mode 100644 index 0000000..577fa2f --- /dev/null +++ b/fpr-bench/src/main/java/dev/relism/fpr/bench/RouterBenchThroughput.java @@ -0,0 +1,83 @@ +package dev.relism.fpr.bench; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.concurrent.TimeUnit; + +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +public class RouterBenchThroughput { + @Benchmark + public int matchArrayLiteral(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayLiteral(blackhole); + } + + @Benchmark + public int matchArrayParam(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayParam(blackhole); + } + + @Benchmark + public int matchArrayMixed(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayMixed(blackhole); + } + + @Benchmark + public int matchArrayCatchAll(RouterBenchState state, Blackhole blackhole) { + return state.matchArrayCatchAll(blackhole); + } + + @Benchmark + public int matchNettyLiteral(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyLiteral(blackhole); + } + + @Benchmark + public int matchNettyParam(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyParam(blackhole); + } + + @Benchmark + public int matchNettyMixed(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyMixed(blackhole); + } + + @Benchmark + public int matchNettyCatchAll(RouterBenchState state, Blackhole blackhole) { + return state.matchNettyCatchAll(blackhole); + } + + @Benchmark + public int matchLiteralHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchLiteralHeavy(blackhole); + } + + @Benchmark + public int matchParamHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchParamHeavy(blackhole); + } + + @Benchmark + public int matchMixedHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchMixedHeavy(blackhole); + } + + @Benchmark + public int matchCatchAllHeavy(RouterBenchState state, Blackhole blackhole) { + return state.matchCatchAllHeavy(blackhole); + } + + @Benchmark + public int matchLargeRouteSet(RouterBenchState state, Blackhole blackhole) { + return state.matchLargeRouteSet(blackhole); + } + + @Benchmark + public int matchVariety(RouterBenchState state, Blackhole blackhole) { + return state.matchVariety(blackhole); + } +} diff --git a/fpr-bench/tools/README.md b/fpr-bench/tools/README.md new file mode 100644 index 0000000..7963b3d --- /dev/null +++ b/fpr-bench/tools/README.md @@ -0,0 +1,55 @@ +# FPR Bench CLI + +Run JMH benchmarks via a single Python CLI and export JSON results. + +Requires Python 3.4+. + +## Run a benchmark +From the repo root: + +```sh +python fpr-bench/tools/fpr_bench.py run +``` + +This builds the shaded JMH jar and writes results to `fpr-bench/results/`. +You will be prompted for benchmark types and common JMH settings. + +Available types: `throughput`, `latency`, `common` (runs the combined bench). + +## Pass JMH arguments +Use `--` to pass flags directly to JMH (this skips the menu): + +```sh +python fpr-bench/tools/fpr_bench.py run -- --wi 5 -i 5 -f 5 -tu us +``` + +## Results location +JSON output is stored in: + +``` +fpr-bench/results/YYYYMMDD_HHMMSS____.json +``` + +If multiple types are selected, one JSON file is produced per type. + +## Examples +Skip the build and use a custom tag: + +```sh +python fpr-bench/tools/fpr_bench.py run --no-build --tag smoke +``` + +Run only latency and throughput types non-interactively: + +```sh +python fpr-bench/tools/fpr_bench.py run --types latency,throughput -- --wi 3 -i 3 -f 1 +``` + +Use custom `mvn` and `java` executables: + +```sh +python fpr-bench/tools/fpr_bench.py run --mvn mvn.cmd --java java +``` + +If another JMH instance is detected, the tool will try to terminate it. If that +fails, it runs with `-Djmh.ignoreLock=true`. diff --git a/fpr-bench/tools/fpr_bench.py b/fpr-bench/tools/fpr_bench.py new file mode 100644 index 0000000..ab5e3d5 --- /dev/null +++ b/fpr-bench/tools/fpr_bench.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +from __future__ import print_function + +import argparse +import json +from datetime import datetime +import subprocess +import sys +import shutil +import os +import tempfile + +try: + from pathlib import Path +except ImportError: + print("Error: Python 3.4+ is required to run this tool.", file=sys.stderr) + sys.exit(2) + +BENCH_TYPES = { + "throughput": "dev.relism.fpr.bench.RouterBenchThroughput", + "latency": "dev.relism.fpr.bench.RouterBenchLatency", + "common": "dev.relism.fpr.bench.RouterBench", +} +BENCH_ORDER = ["throughput", "latency", "common"] + + +def parse_args(argv): + parser = argparse.ArgumentParser( + description="Run FPR JMH benchmarks and export JSON results." + ) + subparsers = parser.add_subparsers(dest="command") + + run_parser = subparsers.add_parser("run", help="Build and run JMH benchmarks.") + run_parser.add_argument("--tag", default="default", help="Tag for the output run folder.") + run_parser.add_argument( + "--no-build", + action="store_true", + help="Skip the Maven build step.", + ) + run_parser.add_argument("--mvn", default="mvn", help="Maven executable path.") + run_parser.add_argument("--java", default="java", help="Java executable path.") + run_parser.add_argument("--jar", help="Path to the shaded JMH jar.") + run_parser.add_argument( + "--types", + help="Comma-separated benchmark types: throughput, latency, common.", + ) + run_parser.add_argument( + "--prof-gc", + action="store_true", + help="Enable JMH GC profiler (-prof gc).", + ) + run_parser.add_argument( + "jmh_args", + nargs=argparse.REMAINDER, + help="Arguments passed to JMH (use -- to separate).", + ) + + args = parser.parse_args(argv) + if args.command is None: + parser.print_help() + return None + return args + + +def repo_root(): + # assumes this script lives under fpr-bench/tools (or similar) + return Path(__file__).resolve().parents[2] + + +def validate_tag(tag): + tag = tag.strip() if tag else "" + if not tag: + return "default" + invalid_chars = set('<>:"/\\|?*') + if any(ch in invalid_chars for ch in tag): + raise ValueError('Tag contains invalid filename characters: <>:"/\\|?*') + return tag + + +def resolve_executable(executable, label): + resolved = shutil.which(executable) + if resolved: + return resolved + if Path(executable).is_file(): + return str(Path(executable)) + raise RuntimeError("{} executable not found: {}".format(label, executable)) + + +def run_subprocess(cmd, cwd, failure_message): + try: + return_code = subprocess.call(cmd, cwd=cwd) + except OSError as exc: + print("{}: {}".format(failure_message, exc), file=sys.stderr) + return 2 + if return_code != 0: + print( + "{} (exit code {})".format(failure_message, return_code), + file=sys.stderr, + ) + return return_code + + +def run_subprocess_capture(cmd, cwd, log_path, failure_message): + """ + Runs a subprocess, captures combined stdout+stderr, and always writes it to log_path. + Returns the process exit code (or 2 on OS errors). + """ + try: + proc = subprocess.Popen( + cmd, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + except OSError as exc: + try: + log_path.write_text(str(exc) + "\n", encoding="utf-8") + except OSError: + pass + print("{}: {}".format(failure_message, exc), file=sys.stderr) + print("Log: {}".format(log_path), file=sys.stderr) + return 2 + + try: + with log_path.open("wb") as log_file: + stream = proc.stdout + if stream is None: + proc.wait() + else: + for chunk in iter(lambda: stream.read(4096), b""): + log_file.write(chunk) + try: + sys.stdout.buffer.write(chunk) + sys.stdout.buffer.flush() + except AttributeError: + sys.stdout.write(chunk.decode("utf-8", errors="replace")) + sys.stdout.flush() + return_code = proc.wait() + except OSError as exc: + print("Warning: could not write log {}: {}".format(log_path, exc), file=sys.stderr) + return_code = proc.wait() + + if return_code != 0: + print("{} (exit code {})".format(failure_message, return_code), file=sys.stderr) + print("Log: {}".format(log_path), file=sys.stderr) + return return_code + + +def prompt_tag(default_tag): + raw = input("Tag [{}]: ".format(default_tag)).strip() + return raw if raw else default_tag + + +def prompt_int(label, default_value): + while True: + raw = input("{} [{}]: ".format(label, default_value)).strip() + if not raw: + return default_value + try: + return int(raw) + except ValueError: + print("Please enter a whole number.") + + +def prompt_optional(label): + raw = input("{} (blank to skip): ".format(label)).strip() + return raw if raw else None + + +def prompt_bench_types(): + while True: + print("Select benchmark types:") + for idx, name in enumerate(BENCH_ORDER, start=1): + print(" {}) {}".format(idx, name)) + print(" a) all") + raw = input("Choice [a]: ").strip().lower() + if raw in ("", "a", "all"): + return list(BENCH_ORDER) + + tokens = [token for token in raw.replace(",", " ").split() if token] + selected = [] + invalid = None + for token in tokens: + if token.isdigit(): + index = int(token) + if 1 <= index <= len(BENCH_ORDER): + name = BENCH_ORDER[index - 1] + if name not in selected: + selected.append(name) + continue + if token in BENCH_TYPES: + if token not in selected: + selected.append(token) + continue + invalid = token + break + + if selected and invalid is None: + return selected + if invalid: + print("Unknown selection: {}".format(invalid)) + else: + print("No valid selections provided.") + + +def prompt_jmh_args(): + jmh_args = [] + wi = prompt_int("Warmup iterations (-wi)", 5) + i = prompt_int("Measurement iterations (-i)", 5) + f = prompt_int("Forks (-f)", 1) + w = prompt_optional("Warmup time (-w, e.g. 1s)") + r = prompt_optional("Measurement time (-r, e.g. 1s)") + t = prompt_optional("Threads (-t)") + tu = prompt_optional("Time unit (-tu, e.g. us)") + + if wi is not None: + jmh_args.extend(["-wi", str(wi)]) + if i is not None: + jmh_args.extend(["-i", str(i)]) + if f is not None: + jmh_args.extend(["-f", str(f)]) + if w: + jmh_args.extend(["-w", w]) + if r: + jmh_args.extend(["-r", r]) + if t: + jmh_args.extend(["-t", t]) + if tu: + jmh_args.extend(["-tu", tu]) + return jmh_args + + +def parse_types_arg(value): + if not value: + return None + tokens = [token.strip().lower() for token in value.split(",") if token.strip()] + if not tokens: + return None + if "all" in tokens: + return list(BENCH_ORDER) + selected = [] + for token in tokens: + if token not in BENCH_TYPES: + raise ValueError("Unknown benchmark type: {}".format(token)) + if token not in selected: + selected.append(token) + return selected + + +def find_jmh_pids_windows(): + command = [ + "powershell", + "-NoProfile", + "-Command", + ( + "Get-CimInstance Win32_Process | " + "Where-Object { $_.Name -match 'java' -and $_.CommandLine -and " + "($_.CommandLine -match 'org\\.openjdk\\.jmh' -or " + "$_.CommandLine -match 'jmh' -or $_.CommandLine -match 'fpr-bench') } | " + "Select-Object -ExpandProperty ProcessId" + ), + ] + try: + output = subprocess.check_output(command, universal_newlines=True) + except (OSError, subprocess.CalledProcessError): + return None + pids = [] + for line in output.splitlines(): + line = line.strip() + if not line: + continue + try: + pids.append(int(line)) + except ValueError: + continue + return pids + + +def find_jmh_pids_unix(): + try: + output = subprocess.check_output( + ["ps", "-ax", "-o", "pid=,command="], universal_newlines=True + ) + except (OSError, subprocess.CalledProcessError): + return None + pids = [] + for line in output.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + pid_str, cmdline = parts + cmd_lower = cmdline.lower() + if "java" not in cmd_lower: + continue + if "org.openjdk.jmh" in cmd_lower or "jmh" in cmd_lower or "fpr-bench" in cmd_lower: + try: + pids.append(int(pid_str)) + except ValueError: + continue + return pids + + +def kill_jmh_processes(): + if os.name == "nt": + pids = find_jmh_pids_windows() + else: + pids = find_jmh_pids_unix() + + if pids is None: + return False + if not pids: + return True + + success = True + if os.name == "nt": + for pid in pids: + return_code = subprocess.call( + ["taskkill", "/PID", str(pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if return_code != 0: + success = False + else: + for pid in pids: + return_code = subprocess.call( + ["kill", "-9", str(pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if return_code != 0: + success = False + return success + + +def should_ignore_lock(kill_success): + if not kill_success: + return True + lock_path = Path(tempfile.gettempdir()) / "jmh.lock" + return lock_path.exists() + + +def find_shaded_jar(bench_dir, override_path): + if override_path: + jar_path = Path(override_path).expanduser() + if not jar_path.is_absolute(): + jar_path = (Path.cwd() / jar_path).resolve() + if not jar_path.is_file(): + raise RuntimeError("Jar not found: {}".format(jar_path)) + return jar_path + + target_dir = bench_dir / "target" + if not target_dir.exists(): + raise RuntimeError("Jar not found: {} does not exist".format(target_dir)) + candidates = sorted( + target_dir.glob("*shaded*.jar"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + if not candidates: + raise RuntimeError("Jar not found: no shaded JMH jar under {}".format(target_dir)) + return candidates[0].resolve() + + +def ensure_results_dir(results_dir): + if results_dir.exists() and not results_dir.is_dir(): + raise RuntimeError("Results path exists but is not a directory: {}".format(results_dir)) + results_dir.mkdir(parents=True, exist_ok=True) + + +def prepare_run_dir(results_dir, timestamp, tag): + safe_tag = validate_tag(tag) + folder_name = "{}__{}".format(timestamp, safe_tag) + run_dir = results_dir / folder_name + run_dir.mkdir(parents=True, exist_ok=True) + return run_dir.resolve() + + +def output_paths_for(run_dir, bench_type, prof_gc): + suffix = "__gc" if prof_gc else "" + json_path = run_dir / "{}{}.json".format(bench_type, suffix) + log_path = run_dir / "{}{}.log".format(bench_type, suffix) + return json_path.resolve(), log_path.resolve() + + +def run_benchmarks(args): + root = repo_root() + bench_dir = root / "fpr-bench" + results_dir = bench_dir / "results" + try: + ensure_results_dir(results_dir) + except RuntimeError as exc: + print("Error: {}".format(exc), file=sys.stderr) + return 2 + + jmh_args = list(args.jmh_args or []) + if jmh_args and jmh_args[0] == "--": + jmh_args = jmh_args[1:] + + interactive = not jmh_args and sys.stdin.isatty() + if interactive: + tag = prompt_tag(args.tag or "default") + else: + tag = args.tag + + try: + tag = validate_tag(tag) + except ValueError as exc: + print("Error: {}".format(exc), file=sys.stderr) + return 2 + + try: + selected_types = parse_types_arg(args.types) + except ValueError as exc: + print("Error: {}".format(exc), file=sys.stderr) + return 2 + if selected_types is None: + if interactive: + selected_types = prompt_bench_types() + else: + selected_types = list(BENCH_ORDER) + + if interactive: + jmh_args = prompt_jmh_args() + + if not args.no_build: + try: + mvn_exec = resolve_executable(args.mvn, "Maven") + except RuntimeError as exc: + print("Error: {}".format(exc), file=sys.stderr) + return 2 + + mvn_cmd = [mvn_exec, "-q", "-pl", "fpr-bench", "-am", "package", "-Dfile.encoding=UTF-8"] + exit_code = run_subprocess(mvn_cmd, root, "Maven build failed") + if exit_code != 0: + return exit_code + + try: + jar_path = find_shaded_jar(bench_dir, args.jar) + except RuntimeError as exc: + print("Error: {}".format(exc), file=sys.stderr) + return 2 + + ignore_lock = should_ignore_lock(kill_jmh_processes()) + if ignore_lock: + print( + "Warning: Unable to terminate existing JMH instance or lock file exists; using -Djmh.ignoreLock=true", + file=sys.stderr, + ) + + try: + java_exec = resolve_executable(args.java, "Java") + except RuntimeError as exc: + print("Error: {}".format(exc), file=sys.stderr) + return 2 + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + run_dir = prepare_run_dir(results_dir, timestamp, tag) + print("Run directory: {}".format(run_dir)) + + prof_gc = bool(getattr(args, "prof_gc", False)) + + for bench_type in selected_types: + bench_class = BENCH_TYPES[bench_type] + json_path, log_path = output_paths_for(run_dir, bench_type, prof_gc) + + # overwrite if exists + try: + if json_path.exists(): + json_path.unlink() + except OSError: + pass + try: + if log_path.exists(): + log_path.unlink() + except OSError: + pass + + jmh_cmd = [java_exec] + if ignore_lock: + jmh_cmd.append("-Djmh.ignoreLock=true") + jmh_cmd.extend(["-jar", str(jar_path)]) + + if prof_gc: + jmh_cmd.extend(["-prof", "gc"]) + + if jmh_args: + jmh_cmd.extend(jmh_args) + + jmh_cmd.append("^{}\\.".format(bench_class)) + jmh_cmd.extend(["-rf", "json", "-rff", str(json_path)]) + + exit_code = run_subprocess_capture( + jmh_cmd, root, log_path, "JMH run failed ({})".format(bench_type) + ) + if exit_code != 0: + return exit_code + + # validate JSON + try: + with json_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, list): + raise ValueError("JSON root is not an array") + except (OSError, ValueError, json.JSONDecodeError) as exc: + print("Error: JSON could not be parsed: {}".format(exc), file=sys.stderr) + print("JSON: {}".format(json_path), file=sys.stderr) + print("Log : {}".format(log_path), file=sys.stderr) + return 2 + + size_bytes = json_path.stat().st_size + print("JMH run completed ({})".format(bench_type)) + print("JSON: {}".format(json_path)) + print("LOG : {}".format(log_path)) + print("File size: {} bytes".format(size_bytes)) + print("Benchmarks: {}".format(len(data))) + + print("All selected benchmarks completed. Results folder: {}".format(run_dir)) + return 0 + + +def main(argv): + args = parse_args(argv) + if args is None: + return 2 + if args.command == "run": + return run_benchmarks(args) + print("Unknown command: {}".format(args.command), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/fpr-core/pom.xml b/fpr-core/pom.xml new file mode 100644 index 0000000..d21c745 --- /dev/null +++ b/fpr-core/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + dev.relism + fastpathrouter-parent + 1.0-SNAPSHOT + + + fpr-core + jar + + + + org.slf4j + slf4j-api + + + org.projectlombok + lombok + provided + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + org.slf4j + slf4j-simple + test + + + diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/ByteView.java b/fpr-core/src/main/java/dev/relism/fpr/core/ByteView.java new file mode 100644 index 0000000..ae688f1 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/ByteView.java @@ -0,0 +1,15 @@ +package dev.relism.fpr.core; + +public interface ByteView { + int length(); + + byte byteAt(int index); + + default boolean supportsLong() { + return false; + } + + default long longAt(int index) { + throw new UnsupportedOperationException("longAt not supported"); + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/FastPathRouter.java b/fpr-core/src/main/java/dev/relism/fpr/core/FastPathRouter.java new file mode 100644 index 0000000..0f852e7 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/FastPathRouter.java @@ -0,0 +1,10 @@ +package dev.relism.fpr.core; + +/** + * Immutable router interface for hot-path matching. + */ +public interface FastPathRouter { + int NO_MATCH = -1; + + int match(I input, MatchResult out); +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/MatchResult.java b/fpr-core/src/main/java/dev/relism/fpr/core/MatchResult.java new file mode 100644 index 0000000..04c0a64 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/MatchResult.java @@ -0,0 +1,211 @@ +package dev.relism.fpr.core; + +import lombok.Setter; + +/** + * Reusable match result container for parameter spans. + */ +public final class MatchResult { + private final int[] keyIds; + private final int[] starts; + private final int[] lens; + private final int[] stackState; + private final int[] stackSegStart; + private final int[] stackSegLen; + private final int[] stackNextIdx; + private final int[] stackParamMark; + private final int[] stackEdgeIndex; + private final byte[] stackKind; + private final int[] scratchKeyIds; + private final int[] scratchStarts; + private final int[] scratchLens; + private final int[] scratchEdges; + private int stackSize; + private int paramCount; + @Setter + private H handler; + private int labelId; + + public MatchResult() { + this(8, 32); + } + + public MatchResult(int maxParams) { + this(maxParams, Math.max(32, maxParams)); + } + + public MatchResult(int maxParams, int maxStack) { + if (maxParams < 0) { + throw new IllegalArgumentException("maxParams must be >= 0"); + } + if (maxStack <= 0) { + throw new IllegalArgumentException("maxStack must be > 0"); + } + this.keyIds = new int[maxParams]; + this.starts = new int[maxParams]; + this.lens = new int[maxParams]; + this.stackState = new int[maxStack]; + this.stackSegStart = new int[maxStack]; + this.stackSegLen = new int[maxStack]; + this.stackNextIdx = new int[maxStack]; + this.stackParamMark = new int[maxStack]; + this.stackEdgeIndex = new int[maxStack]; + this.stackKind = new byte[maxStack]; + this.scratchKeyIds = new int[maxParams]; + this.scratchStarts = new int[maxParams]; + this.scratchLens = new int[maxParams]; + this.scratchEdges = new int[maxStack]; + } + + /** + * Clears handler and params but keeps label id. + */ + public MatchResult reset() { + this.paramCount = 0; + this.stackSize = 0; + this.handler = null; + return this; + } + + public MatchResult labelId(int labelId) { + this.labelId = labelId; + return this; + } + + public int labelId() { + return labelId; + } + + public int paramCount() { + return paramCount; + } + + /** + * Iterates params without allocations using a precomputed name array. + * Use {@link RouterBuilder#paramNames()} to obtain the name array. + */ + public void forEachParam(ByteView view, String[] paramNames, ParamConsumer consumer) { + if (view == null) { + throw new IllegalArgumentException("view must not be null"); + } + if (paramNames == null) { + throw new IllegalArgumentException("paramNames must not be null"); + } + if (consumer == null) { + throw new IllegalArgumentException("consumer must not be null"); + } + for (int i = 0; i < paramCount; i++) { + int keyId = keyIds[i]; + if (keyId < 0 || keyId >= paramNames.length) { + throw new IllegalArgumentException("param name missing for keyId " + keyId); + } + consumer.accept(paramNames[keyId], view, starts[i], lens[i]); + } + } + + public int keyIdAt(int index) { + return keyIds[index]; + } + + public int startAt(int index) { + return starts[index]; + } + + public int lenAt(int index) { + return lens[index]; + } + + public H handler() { + return handler; + } + + public int mark() { + return paramCount; + } + + public void rollbackTo(int mark) { + paramCount = mark; + } + + public void resetTo(int mark) { + paramCount = mark; + } + + public void addParam(int keyId, int start, int len) { + if (paramCount >= keyIds.length) { + throw new IllegalStateException("MatchResult capacity exceeded"); + } + keyIds[paramCount] = keyId; + starts[paramCount] = start; + lens[paramCount] = len; + paramCount++; + } + + int[] stackStateArray() { + return stackState; + } + + int[] stackSegStartArray() { + return stackSegStart; + } + + int[] stackSegLenArray() { + return stackSegLen; + } + + int[] stackNextIdxArray() { + return stackNextIdx; + } + + int[] stackParamMarkArray() { + return stackParamMark; + } + + int[] stackEdgeIndexArray() { + return stackEdgeIndex; + } + + byte[] stackKindArray() { + return stackKind; + } + + int stackSize() { + return stackSize; + } + + void stackSize(int size) { + this.stackSize = size; + } + + int[] keyIdsArray() { + return keyIds; + } + + int[] startsArray() { + return starts; + } + + int[] lensArray() { + return lens; + } + + int[] scratchKeyIdsArray() { + return scratchKeyIds; + } + + int[] scratchStartsArray() { + return scratchStarts; + } + + int[] scratchLensArray() { + return scratchLens; + } + + int[] scratchEdgesArray() { + return scratchEdges; + } + + void paramCount(int count) { + this.paramCount = count; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/MatchResultAccess.java b/fpr-core/src/main/java/dev/relism/fpr/core/MatchResultAccess.java new file mode 100644 index 0000000..ba1b7b0 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/MatchResultAccess.java @@ -0,0 +1,77 @@ +package dev.relism.fpr.core; + +/** + * Internal access bridge for match-time scratch storage. + */ +public final class MatchResultAccess { + private MatchResultAccess() { + } + + public static int[] stackState(MatchResult result) { + return result.stackStateArray(); + } + + public static int[] stackSegStart(MatchResult result) { + return result.stackSegStartArray(); + } + + public static int[] stackSegLen(MatchResult result) { + return result.stackSegLenArray(); + } + + public static int[] stackNextIdx(MatchResult result) { + return result.stackNextIdxArray(); + } + + public static int[] stackParamMark(MatchResult result) { + return result.stackParamMarkArray(); + } + + public static int[] stackEdgeIndex(MatchResult result) { + return result.stackEdgeIndexArray(); + } + + public static byte[] stackKind(MatchResult result) { + return result.stackKindArray(); + } + + public static int[] keyIds(MatchResult result) { + return result.keyIdsArray(); + } + + public static int[] starts(MatchResult result) { + return result.startsArray(); + } + + public static int[] lens(MatchResult result) { + return result.lensArray(); + } + + public static int[] scratchKeyIds(MatchResult result) { + return result.scratchKeyIdsArray(); + } + + public static int[] scratchStarts(MatchResult result) { + return result.scratchStartsArray(); + } + + public static int[] scratchLens(MatchResult result) { + return result.scratchLensArray(); + } + + public static int[] scratchEdges(MatchResult result) { + return result.scratchEdgesArray(); + } + + public static int stackSize(MatchResult result) { + return result.stackSize(); + } + + public static void stackSize(MatchResult result, int size) { + result.stackSize(size); + } + + public static void paramCount(MatchResult result, int count) { + result.paramCount(count); + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/ParamConsumer.java b/fpr-core/src/main/java/dev/relism/fpr/core/ParamConsumer.java new file mode 100644 index 0000000..54678aa --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/ParamConsumer.java @@ -0,0 +1,9 @@ +package dev.relism.fpr.core; + +@FunctionalInterface +public interface ParamConsumer { + /** + * Receives a param name and its byte span in the matched input. + */ + void accept(String name, ByteView view, int start, int len); +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/RoutePattern.java b/fpr-core/src/main/java/dev/relism/fpr/core/RoutePattern.java new file mode 100644 index 0000000..84dfb87 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/RoutePattern.java @@ -0,0 +1,153 @@ +package dev.relism.fpr.core; + +import java.util.Arrays; +import java.util.List; + +/** + * Immutable route pattern built from segment tokens. + */ +public final class RoutePattern { + private final List segments; + + RoutePattern(List segments) { + this.segments = segments; + } + + public List segments() { + return segments; + } + + public static RoutePattern of(Segment... segments) { + return new RoutePattern(Arrays.asList(segments)); + } + + public static RoutePattern fromSegments(List segments) { + return new RoutePattern(segments); + } + + public static Literal literal(String text) { + return new Literal(text); + } + + public static Param param(String name) { + return new Param(name); + } + + public static Wildcard wildcard() { + return new Wildcard(); + } + + public static CatchAll catchAll() { + return new CatchAll(null); + } + + public static CatchAll catchAll(String name) { + return new CatchAll(name); + } + + public static Mixed mixed(String[] literals, String[] params) { + return new Mixed(literals, params); + } + + public enum SegmentType { + LITERAL, + PARAM, + WILDCARD, + CATCH_ALL, + MIXED + } + + public interface Segment { + SegmentType type(); + } + + public static final class Literal implements Segment { + private final String text; + + public Literal(String text) { + if (text == null || text.isEmpty()) { + throw new IllegalArgumentException("literal must be non-empty"); + } + this.text = text; + } + + public String text() { + return text; + } + + @Override + public SegmentType type() { + return SegmentType.LITERAL; + } + } + + public static final class Param implements Segment { + private final String name; + + public Param(String name) { + if (name == null || name.isEmpty()) { + throw new IllegalArgumentException("param name must be non-empty"); + } + this.name = name; + } + + public String name() { + return name; + } + + @Override + public SegmentType type() { + return SegmentType.PARAM; + } + } + + public static final class Wildcard implements Segment { + @Override + public SegmentType type() { + return SegmentType.WILDCARD; + } + } + + public static final class CatchAll implements Segment { + private final String name; + + public CatchAll(String name) { + this.name = name; + } + + public String name() { + return name; + } + + @Override + public SegmentType type() { + return SegmentType.CATCH_ALL; + } + } + + public static final class Mixed implements Segment { + private final String[] literals; + private final String[] params; + + public Mixed(String[] literals, String[] params) { + if (literals == null || params == null || literals.length != params.length + 1) { + throw new IllegalArgumentException("mixed segment requires literals=paramCount+1"); + } + this.literals = literals; + this.params = params; + } + + public String[] literals() { + return literals; + } + + public String[] params() { + return params; + } + + @Override + public SegmentType type() { + return SegmentType.MIXED; + } + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/RouterBuilder.java b/fpr-core/src/main/java/dev/relism/fpr/core/RouterBuilder.java new file mode 100644 index 0000000..ebc52ee --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/RouterBuilder.java @@ -0,0 +1,144 @@ +package dev.relism.fpr.core; + +import dev.relism.fpr.core.internal.compile.RouteCompiler; +import lombok.Getter; +import lombok.experimental.Accessors; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Builder for creating and compiling immutable routers. + */ +public final class RouterBuilder { + private final List> routes = new ArrayList<>(); + private final LinkedHashMap paramIds = new LinkedHashMap<>(); + private final List paramNames = new ArrayList<>(); + private final LinkedHashMap labelIds = new LinkedHashMap<>(); + + public RouterBuilder add(RoutePattern pattern, H handler) { + return add(0, pattern, handler); + } + + public RouterBuilder add(String label, RoutePattern pattern, H handler) { + return add(labelId(label), pattern, handler); + } + + public RouterBuilder add(Enum label, RoutePattern pattern, H handler) { + return add(labelId(label), pattern, handler); + } + + public RouterBuilder add(String[] labels, RoutePattern pattern, H handler) { + if (labels == null || labels.length == 0) { + return add(0, pattern, handler); + } + for (String label : labels) { + add(labelId(label), pattern, handler); + } + return this; + } + + public RouterBuilder add(Enum[] labels, RoutePattern pattern, H handler) { + if (labels == null || labels.length == 0) { + return add(0, pattern, handler); + } + for (Enum label : labels) { + add(labelId(label), pattern, handler); + } + return this; + } + + public RouterBuilder add(int labelId, RoutePattern pattern, H handler) { + if (pattern == null) { + throw new IllegalArgumentException("pattern must not be null"); + } + if (labelId < 0) { + throw new IllegalArgumentException("labelId must be >= 0"); + } + registerParams(pattern); + routes.add(new RouteSpec<>(labelId, pattern, handler, routes.size())); + return this; + } + + public int maxParamCount() { + return paramIds.size(); + } + + /** + * Returns param names by key id order for optional zero-copy extraction. + * Cache the returned array for reuse; it is stable after route registration. + */ + public String[] paramNames() { + return paramNames.toArray(new String[0]); + } + + public int labelId(String label) { + if (label == null || label.isEmpty()) { + return 0; + } + Integer existing = labelIds.get(label); + if (existing != null) { + return existing; + } + int id = labelIds.size() + 1; + labelIds.put(label, id); + return id; + } + + public int labelId(Enum label) { + if (label == null) { + return 0; + } + String key = label.getDeclaringClass().getName() + "#" + label.name(); + return labelId(key); + } + + public FastPathRouter compile() { + return RouteCompiler.compile(routes, paramIds); + } + + private void registerParams(RoutePattern pattern) { + for (RoutePattern.Segment segment : pattern.segments()) { + if (segment instanceof RoutePattern.Param) { + paramId(((RoutePattern.Param) segment).name()); + } else if (segment instanceof RoutePattern.CatchAll) { + String name = ((RoutePattern.CatchAll) segment).name(); + if (name != null && !name.isEmpty()) { + paramId(name); + } + } else if (segment instanceof RoutePattern.Mixed) { + for (String name : ((RoutePattern.Mixed) segment).params()) { + paramId(name); + } + } + } + } + + private int paramId(String name) { + Integer existing = paramIds.get(name); + if (existing != null) { + return existing; + } + int id = paramIds.size(); + paramIds.put(name, id); + paramNames.add(name); + return id; + } + + @Getter + @Accessors(fluent = true) + public static final class RouteSpec { + private final int labelId; + private final RoutePattern pattern; + private final H handler; + private final int order; + + public RouteSpec(int labelId, RoutePattern pattern, H handler, int order) { + this.labelId = labelId; + this.pattern = pattern; + this.handler = handler; + this.order = order; + } + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/dsl/StringRouteParser.java b/fpr-core/src/main/java/dev/relism/fpr/core/dsl/StringRouteParser.java new file mode 100644 index 0000000..97df829 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/dsl/StringRouteParser.java @@ -0,0 +1,130 @@ +package dev.relism.fpr.core.dsl; + +import dev.relism.fpr.core.RoutePattern; + +import java.util.ArrayList; +import java.util.List; + +/** + * Cold-path string parser for building RoutePattern instances. + */ +public final class StringRouteParser { + private StringRouteParser() { + } + + public static RoutePattern parse(String path) { + if (path == null) { + throw new IllegalArgumentException("path must not be null"); + } + String trimmed = path.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("path must not be empty"); + } + List segments = new ArrayList<>(); + int start = 0; + int len = trimmed.length(); + if (trimmed.charAt(0) == '/') { + start = 1; + } + while (true) { + int slash = trimmed.indexOf('/', start); + if (slash == -1) { + slash = len; + } + if (slash > start) { + segments.add(parseSegment(trimmed.substring(start, slash))); + } else if (slash == len) { + break; + } else { + throw new IllegalArgumentException("empty segment in path: " + path); + } + start = slash + 1; + if (start > len) { + break; + } + } + return RoutePattern.fromSegments(segments); + } + + private static RoutePattern.Segment parseSegment(String segment) { + if (segment.equals("*")) { + return RoutePattern.wildcard(); + } + if (segment.equals("**")) { + return RoutePattern.catchAll(); + } + int len = segment.length(); + if (len >= 2 && segment.charAt(0) == '{' && segment.charAt(len - 1) == '}' + && segment.indexOf('{', 1) == -1 && segment.indexOf('}') == len - 1) { + String name = segment.substring(1, len - 1); + if (name.isEmpty()) { + throw new IllegalArgumentException("param segment requires name"); + } + if (!isValidParamName(name)) { + throw new IllegalArgumentException("param segment has invalid name: " + segment); + } + return RoutePattern.param(name); + } + if (segment.indexOf('{') >= 0 || segment.indexOf('}') >= 0) { + if (segment.indexOf('{') < 0) { + throw new IllegalArgumentException("mixed segment has stray closing brace: " + segment); + } + return parseMixed(segment); + } + return RoutePattern.literal(segment); + } + + private static RoutePattern.Segment parseMixed(String segment) { + List literals = new ArrayList<>(); + List params = new ArrayList<>(); + int i = 0; + int len = segment.length(); + while (i < len) { + int open = segment.indexOf('{', i); + int close = segment.indexOf('}', i); + if (close >= 0 && (open < 0 || close < open)) { + throw new IllegalArgumentException("mixed segment has stray closing brace: " + segment); + } + if (open < 0) { + literals.add(segment.substring(i)); + i = len; + break; + } + literals.add(segment.substring(i, open)); + int nameStart = open + 1; + int nameEnd = segment.indexOf('}', nameStart); + if (nameEnd < 0) { + throw new IllegalArgumentException("mixed segment has unterminated param: " + segment); + } + if (nameEnd == nameStart) { + throw new IllegalArgumentException("mixed segment has empty param name: " + segment); + } + String name = segment.substring(nameStart, nameEnd); + if (!isValidParamName(name)) { + throw new IllegalArgumentException("mixed segment has invalid param name: " + segment); + } + params.add(name); + i = nameEnd + 1; + } + if (params.isEmpty()) { + return RoutePattern.literal(segment); + } + if (literals.size() == params.size()) { + literals.add(""); + } + if (literals.size() != params.size() + 1) { + throw new IllegalArgumentException("mixed segment literals/params mismatch: " + segment); + } + return RoutePattern.mixed(literals.toArray(new String[0]), params.toArray(new String[0])); + } + + private static boolean isValidParamName(String name) { + for (int i = 0; i < name.length(); i++) { + char ch = name.charAt(i); + if (!(Character.isLetterOrDigit(ch) || ch == '_')) { + return false; + } + } + return true; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/FreezeWriter.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/FreezeWriter.java new file mode 100644 index 0000000..97d6b3e --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/FreezeWriter.java @@ -0,0 +1,254 @@ +package dev.relism.fpr.core.internal.compile; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.FastPathRouter; +import dev.relism.fpr.core.internal.compile.lookup.LiteralLookupPlanBuilder; +import dev.relism.fpr.core.internal.compile.lookup.MixedLookupPlanBuilder; +import dev.relism.fpr.core.internal.runtime.EdgeKind; +import dev.relism.fpr.core.internal.runtime.FrozenRouter; +import dev.relism.fpr.core.internal.runtime.lookup.LiteralLookupStrategy; + +import java.util.Arrays; +import java.util.List; + +final class FreezeWriter { + private static final int INDEX_THRESHOLD = 12; + + private FreezeWriter() { + } + + static FastPathRouter freeze(RouteGraph graph) { + List> nodes = graph.nodes; + List handlers = graph.handlers; + + int stateCount = nodes.size(); + int totalEdges = 0; + int totalAccepts = 0; + for (RouteGraph.Node node : nodes) { + totalEdges += node.edges.size(); + totalAccepts += node.accepts.size(); + } + + PrimitiveBuilders.ByteBlobBuilder blob = new PrimitiveBuilders.ByteBlobBuilder(1024); + PrimitiveBuilders.IntArrayList edgeNextState = new PrimitiveBuilders.IntArrayList(totalEdges); + PrimitiveBuilders.IntArrayList edgeLabelOff = new PrimitiveBuilders.IntArrayList(totalEdges); + PrimitiveBuilders.ShortArrayList edgeLabelLen = new PrimitiveBuilders.ShortArrayList(totalEdges); + PrimitiveBuilders.LongArrayList edgeLiteralPrefix = new PrimitiveBuilders.LongArrayList(totalEdges); + PrimitiveBuilders.ByteArrayList edgeKind = new PrimitiveBuilders.ByteArrayList(totalEdges); + PrimitiveBuilders.IntArrayList edgeMixedChunkOff = new PrimitiveBuilders.IntArrayList(totalEdges); + PrimitiveBuilders.ShortArrayList edgeMixedChunkCount = new PrimitiveBuilders.ShortArrayList(totalEdges); + PrimitiveBuilders.IntArrayList edgeMixedParamOff = new PrimitiveBuilders.IntArrayList(totalEdges); + PrimitiveBuilders.ShortArrayList edgeMixedParamCount = new PrimitiveBuilders.ShortArrayList(totalEdges); + PrimitiveBuilders.ByteArrayList edgeMixedStrategy = new PrimitiveBuilders.ByteArrayList(totalEdges); + + PrimitiveBuilders.IntArrayList mixedChunkOff = new PrimitiveBuilders.IntArrayList(totalEdges * 2); + PrimitiveBuilders.ShortArrayList mixedChunkLen = new PrimitiveBuilders.ShortArrayList(totalEdges * 2); + PrimitiveBuilders.ShortArrayList mixedParamKeyId = new PrimitiveBuilders.ShortArrayList(totalEdges * 2); + + PrimitiveBuilders.IntArrayList acceptHandlerId = new PrimitiveBuilders.IntArrayList(totalAccepts); + PrimitiveBuilders.IntArrayList acceptLabelId = new PrimitiveBuilders.IntArrayList(totalAccepts); + PrimitiveBuilders.IntArrayList acceptRouteId = new PrimitiveBuilders.IntArrayList(totalAccepts); + + PrimitiveBuilders.IntArrayList indexStart = new PrimitiveBuilders.IntArrayList(stateCount * 4); + PrimitiveBuilders.ShortArrayList indexCount = new PrimitiveBuilders.ShortArrayList(stateCount * 4); + PrimitiveBuilders.IntArrayList indexSecondOff = new PrimitiveBuilders.IntArrayList(stateCount * 4); + PrimitiveBuilders.LongArrayList literalHashKey = new PrimitiveBuilders.LongArrayList(stateCount * 8); + PrimitiveBuilders.IntArrayList literalHashEdge = new PrimitiveBuilders.IntArrayList(stateCount * 8); + + int[] stateFirstEdge = new int[stateCount]; + short[] stateEdgeCount = new short[stateCount]; + int[] stateLiteralStart = new int[stateCount]; + short[] stateLiteralCount = new short[stateCount]; + byte[] stateLiteralStrategy = new byte[stateCount]; + int[] stateLiteralHashOff = new int[stateCount]; + int[] stateLiteralHashMask = new int[stateCount]; + int[] stateMixedStart = new int[stateCount]; + short[] stateMixedCount = new short[stateCount]; + short[] stateMixedPrefixCount = new short[stateCount]; + int[] stateWildIndex = new int[stateCount]; + int[] stateParamNext = new int[stateCount]; + short[] stateParamKeyId = new short[stateCount]; + int[] stateCatchAllNext = new int[stateCount]; + short[] stateCatchAllKeyId = new short[stateCount]; + int[] stateAcceptFirst = new int[stateCount]; + short[] stateAcceptCount = new short[stateCount]; + int[] stateLiteralIndexOff = new int[stateCount]; + int[] stateMixedIndexOff = new int[stateCount]; + + Arrays.fill(stateWildIndex, -1); + Arrays.fill(stateParamNext, -1); + Arrays.fill(stateCatchAllNext, -1); + Arrays.fill(stateParamKeyId, (short) -1); + Arrays.fill(stateCatchAllKeyId, (short) -1); + Arrays.fill(stateLiteralIndexOff, -1); + Arrays.fill(stateMixedIndexOff, -1); + Arrays.fill(stateLiteralHashOff, -1); + Arrays.fill(stateLiteralHashMask, -1); + Arrays.fill(stateLiteralStrategy, LiteralLookupStrategy.LINEAR); + + for (int s = 0; s < stateCount; s++) { + RouteGraph.Node node = nodes.get(s); + stateFirstEdge[s] = edgeNextState.size(); + + List literals = node.literalEdges(); + List mixed = node.mixedEdges(); + RouteGraph.Edge wild = node.wildEdge(); + + literals.sort(RouteGraph.Edge.literalComparator()); + mixed.sort(RouteGraph.Edge.mixedComparator()); + + int literalStart = edgeNextState.size(); + for (RouteGraph.Edge edge : literals) { + int off = blob.append(edge.literal); + edgeNextState.add(edge.nextState); + edgeLabelOff.add(off); + edgeLabelLen.add((short) edge.literal.length); + edgeLiteralPrefix.add(LiteralLookupPlanBuilder.prefixKey(edge.literal)); + edgeKind.add((byte) edge.kind.ordinal()); + edgeMixedChunkOff.add(-1); + edgeMixedChunkCount.add((short) 0); + edgeMixedParamOff.add(-1); + edgeMixedParamCount.add((short) 0); + edgeMixedStrategy.add((byte) 0); + } + int literalCount = literals.size(); + + int mixedStart = edgeNextState.size(); + int mixedPrefixCount = 0; + for (RouteGraph.Edge edge : mixed) { + int chunkBase = mixedChunkOff.size(); + for (byte[] chunk : edge.mixed.literals) { + int off = blob.append(chunk); + mixedChunkOff.add(off); + mixedChunkLen.add((short) chunk.length); + } + int paramBase = mixedParamKeyId.size(); + for (short key : edge.mixed.paramKeys) { + mixedParamKeyId.add(key); + } + int firstOff = mixedChunkOff.get(chunkBase); + short firstLen = mixedChunkLen.get(chunkBase); + + edgeNextState.add(edge.nextState); + edgeLabelOff.add(firstOff); + edgeLabelLen.add(firstLen); + edgeLiteralPrefix.add(0L); + edgeKind.add((byte) edge.kind.ordinal()); + edgeMixedChunkOff.add(chunkBase); + edgeMixedChunkCount.add((short) edge.mixed.literals.length); + edgeMixedParamOff.add(paramBase); + edgeMixedParamCount.add((short) edge.mixed.paramKeys.length); + edgeMixedStrategy.add(MixedLookupPlanBuilder.strategyForParamCount(edge.mixed.paramKeys.length)); + + if (firstLen > 0) { + mixedPrefixCount++; + } + } + int mixedCount = mixed.size(); + + if (wild != null) { + stateWildIndex[s] = edgeNextState.size(); + edgeNextState.add(wild.nextState); + edgeLabelOff.add(0); + edgeLabelLen.add((short) 0); + edgeLiteralPrefix.add(0L); + edgeKind.add((byte) EdgeKind.WILD.ordinal()); + edgeMixedChunkOff.add(-1); + edgeMixedChunkCount.add((short) 0); + edgeMixedParamOff.add(-1); + edgeMixedParamCount.add((short) 0); + edgeMixedStrategy.add((byte) 0); + } + + int edgeCount = edgeNextState.size() - stateFirstEdge[s]; + stateEdgeCount[s] = (short) edgeCount; + stateLiteralStart[s] = literalStart; + stateLiteralCount[s] = (short) literalCount; + stateMixedStart[s] = mixedStart; + stateMixedCount[s] = (short) mixedCount; + stateMixedPrefixCount[s] = (short) mixedPrefixCount; + + if (literalCount > 0) { + byte literalStrategy = LiteralLookupPlanBuilder.selectStrategy(literalCount); + stateLiteralStrategy[s] = literalStrategy; + if (literalStrategy == LiteralLookupStrategy.HASH) { + LiteralLookupPlanBuilder.HashPlan plan = LiteralLookupPlanBuilder.buildHash( + literalHashKey, + literalHashEdge, + edgeLiteralPrefix, + edgeLabelLen, + literalStart, + literalCount + ); + stateLiteralHashOff[s] = plan.offset; + stateLiteralHashMask[s] = plan.mask; + } + } + if (mixedPrefixCount > 0 && mixedCount >= INDEX_THRESHOLD) { + stateMixedIndexOff[s] = IndexBuilder.buildIndex(indexStart, indexCount, indexSecondOff, edgeLabelOff, edgeLabelLen, + blob, mixedStart, mixedCount, false); + } + + stateParamNext[s] = node.paramNext; + stateParamKeyId[s] = node.paramKeyId; + stateCatchAllNext[s] = node.catchAllNext; + stateCatchAllKeyId[s] = node.catchAllKeyId; + + stateAcceptFirst[s] = acceptHandlerId.size(); + for (RouteGraph.Accept accept : node.accepts) { + acceptHandlerId.add(accept.handlerId); + acceptLabelId.add(accept.labelId); + acceptRouteId.add(accept.routeId); + } + stateAcceptCount[s] = (short) node.accepts.size(); + } + + H[] handlerArray = (H[]) handlers.toArray(new Object[0]); + + return new FrozenRouter<>( + blob.toArray(), + handlerArray, + stateFirstEdge, + stateEdgeCount, + stateLiteralStart, + stateLiteralCount, + stateLiteralStrategy, + stateLiteralHashOff, + stateLiteralHashMask, + stateMixedStart, + stateMixedCount, + stateMixedPrefixCount, + stateWildIndex, + stateParamNext, + stateParamKeyId, + stateCatchAllNext, + stateCatchAllKeyId, + stateAcceptFirst, + stateAcceptCount, + stateLiteralIndexOff, + stateMixedIndexOff, + edgeNextState.toArray(), + edgeLabelOff.toArray(), + edgeLabelLen.toArray(), + edgeLiteralPrefix.toArray(), + edgeKind.toArray(), + edgeMixedChunkOff.toArray(), + edgeMixedChunkCount.toArray(), + edgeMixedParamOff.toArray(), + edgeMixedParamCount.toArray(), + edgeMixedStrategy.toArray(), + mixedChunkOff.toArray(), + mixedChunkLen.toArray(), + mixedParamKeyId.toArray(), + acceptHandlerId.toArray(), + acceptLabelId.toArray(), + acceptRouteId.toArray(), + indexStart.toArray(), + indexCount.toArray(), + indexSecondOff.toArray(), + literalHashKey.toArray(), + literalHashEdge.toArray() + ); + } + +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/IndexBuilder.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/IndexBuilder.java new file mode 100644 index 0000000..7852913 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/IndexBuilder.java @@ -0,0 +1,102 @@ +package dev.relism.fpr.core.internal.compile; + +import lombok.NoArgsConstructor; + +import java.util.Arrays; + +@NoArgsConstructor +final class IndexBuilder { + private static final int SECOND_LEVEL_THRESHOLD = 64; + + static int buildIndex(PrimitiveBuilders.IntArrayList indexStart, + PrimitiveBuilders.ShortArrayList indexCount, + PrimitiveBuilders.IntArrayList indexSecondOff, + PrimitiveBuilders.IntArrayList edgeLabelOff, + PrimitiveBuilders.ShortArrayList edgeLabelLen, + PrimitiveBuilders.ByteBlobBuilder blob, + int start, + int count, + boolean allowSecondByte) { + int base = allocateTable(indexStart, indexCount, indexSecondOff); + int[] startTmp = new int[256]; + short[] countTmp = new short[256]; + int[] len2Count = new int[256]; + Arrays.fill(startTmp, -1); + + int end = start + count; + for (int i = start; i < end; i++) { + int off = edgeLabelOff.get(i); + int len = edgeLabelLen.get(i); + if (len == 0) { + continue; + } + int first = blob.byteAt(off) & 0xFF; + if (startTmp[first] == -1) { + startTmp[first] = i - start; + } + countTmp[first]++; + if (allowSecondByte && len > 1) { + len2Count[first]++; + } + } + + for (int i = 0; i < 256; i++) { + indexStart.set(base + i, startTmp[i]); + indexCount.set(base + i, countTmp[i]); + } + + if (!allowSecondByte) { + return base; + } + + for (int first = 0; first < 256; first++) { + int bucketCount = countTmp[first]; + if (bucketCount < SECOND_LEVEL_THRESHOLD || len2Count[first] == 0) { + continue; + } + int relStart = startTmp[first]; + if (relStart < 0) { + continue; + } + int bucketStart = start + relStart; + int bucketEnd = bucketStart + bucketCount; + + int secondBase = allocateTable(indexStart, indexCount, indexSecondOff); + int[] secondStart = new int[256]; + short[] secondCount = new short[256]; + Arrays.fill(secondStart, -1); + + for (int i = bucketStart; i < bucketEnd; i++) { + int len = edgeLabelLen.get(i); + if (len <= 1) { + continue; + } + int off = edgeLabelOff.get(i); + int second = blob.byteAt(off + 1) & 0xFF; + if (secondStart[second] == -1) { + secondStart[second] = i - start; + } + secondCount[second]++; + } + + for (int i = 0; i < 256; i++) { + indexStart.set(secondBase + i, secondStart[i]); + indexCount.set(secondBase + i, secondCount[i]); + } + indexSecondOff.set(base + first, secondBase); + } + return base; + } + + private static int allocateTable(PrimitiveBuilders.IntArrayList indexStart, + PrimitiveBuilders.ShortArrayList indexCount, + PrimitiveBuilders.IntArrayList indexSecondOff) { + int base = indexStart.size(); + for (int i = 0; i < 256; i++) { + indexStart.add(-1); + indexCount.add((short) 0); + indexSecondOff.add(-1); + } + return base; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/PrimitiveBuilders.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/PrimitiveBuilders.java new file mode 100644 index 0000000..1aa9666 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/PrimitiveBuilders.java @@ -0,0 +1,204 @@ +package dev.relism.fpr.core.internal.compile; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PrimitiveBuilders { + public static final class IntArrayList { + private int[] data; + private int size; + + public IntArrayList(int initial) { + this.data = new int[Math.max(8, initial)]; + } + + public int size() { + return size; + } + + public int get(int index) { + return data[index]; + } + + public void set(int index, int value) { + data[index] = value; + } + + public void add(int value) { + ensure(size + 1); + data[size++] = value; + } + + public int[] toArray() { + int[] out = new int[size]; + System.arraycopy(data, 0, out, 0, size); + return out; + } + + private void ensure(int target) { + if (target <= data.length) { + return; + } + int newCap = Math.max(target, data.length * 2); + int[] next = new int[newCap]; + System.arraycopy(data, 0, next, 0, size); + data = next; + } + } + + public static final class ShortArrayList { + private short[] data; + private int size; + + public ShortArrayList(int initial) { + this.data = new short[Math.max(8, initial)]; + } + + public int size() { + return size; + } + + public short get(int index) { + return data[index]; + } + + public void set(int index, short value) { + data[index] = value; + } + + public void add(short value) { + ensure(size + 1); + data[size++] = value; + } + + public short[] toArray() { + short[] out = new short[size]; + System.arraycopy(data, 0, out, 0, size); + return out; + } + + private void ensure(int target) { + if (target <= data.length) { + return; + } + int newCap = Math.max(target, data.length * 2); + short[] next = new short[newCap]; + System.arraycopy(data, 0, next, 0, size); + data = next; + } + } + + public static final class ByteArrayList { + private byte[] data; + private int size; + + public ByteArrayList(int initial) { + this.data = new byte[Math.max(8, initial)]; + } + + public int size() { + return size; + } + + public void add(byte value) { + ensure(size + 1); + data[size++] = value; + } + + public byte[] toArray() { + byte[] out = new byte[size]; + System.arraycopy(data, 0, out, 0, size); + return out; + } + + private void ensure(int target) { + if (target <= data.length) { + return; + } + int newCap = Math.max(target, data.length * 2); + byte[] next = new byte[newCap]; + System.arraycopy(data, 0, next, 0, size); + data = next; + } + } + + public static final class LongArrayList { + private long[] data; + private int size; + + public LongArrayList(int initial) { + this.data = new long[Math.max(8, initial)]; + } + + public int size() { + return size; + } + + public long get(int index) { + return data[index]; + } + + public void set(int index, long value) { + data[index] = value; + } + + public void add(long value) { + ensure(size + 1); + data[size++] = value; + } + + public long[] toArray() { + long[] out = new long[size]; + System.arraycopy(data, 0, out, 0, size); + return out; + } + + private void ensure(int target) { + if (target <= data.length) { + return; + } + int newCap = Math.max(target, data.length * 2); + long[] next = new long[newCap]; + System.arraycopy(data, 0, next, 0, size); + data = next; + } + } + + public static final class ByteBlobBuilder { + private byte[] data; + private int size; + + public ByteBlobBuilder(int initial) { + this.data = new byte[Math.max(16, initial)]; + } + + public int append(byte[] bytes) { + int off = size; + ensure(size + bytes.length); + System.arraycopy(bytes, 0, data, size, bytes.length); + size += bytes.length; + return off; + } + + public byte byteAt(int index) { + return data[index]; + } + + public byte[] toArray() { + byte[] out = new byte[size]; + System.arraycopy(data, 0, out, 0, size); + return out; + } + + private void ensure(int target) { + if (target <= data.length) { + return; + } + int newCap = Math.max(target, data.length * 2); + byte[] next = new byte[newCap]; + System.arraycopy(data, 0, next, 0, size); + data = next; + } + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/RouteCompiler.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/RouteCompiler.java new file mode 100644 index 0000000..e6fa220 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/RouteCompiler.java @@ -0,0 +1,70 @@ +package dev.relism.fpr.core.internal.compile; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.FastPathRouter; +import dev.relism.fpr.core.internal.runtime.FrozenRouter; +import dev.relism.fpr.core.RouterBuilder; + +import java.util.List; +import java.util.Map; + +public final class RouteCompiler { + private RouteCompiler() { + } + + public static FastPathRouter compile(List> routes, + Map paramIds) { + if (routes.isEmpty()) { + return emptyRouter(); + } + RouteGraph graph = RouteGraph.build(routes, paramIds).canonicalize(); + return FreezeWriter.freeze(graph); + } + + private static FastPathRouter emptyRouter() { + return new FrozenRouter<>( + new byte[0], + (H[]) new Object[0], + new int[]{0}, // stateFirstEdge + new short[]{0}, // stateEdgeCount + new int[]{0}, // stateLiteralStart + new short[]{0}, // stateLiteralCount + new byte[]{0}, // stateLiteralStrategy + new int[]{-1}, // stateLiteralHashOff + new int[]{-1}, // stateLiteralHashMask + new int[]{0}, // stateMixedStart + new short[]{0}, // stateMixedCount + new short[]{0}, // stateMixedPrefixCount + new int[]{-1}, // stateWildIndex + new int[]{-1}, // stateParamNext + new short[]{-1}, // stateParamKeyId + new int[]{-1}, // stateCatchAllNext + new short[]{-1}, // stateCatchAllKeyId + new int[]{0}, // stateAcceptFirst + new short[]{0}, // stateAcceptCount + new int[]{-1}, // stateLiteralIndexOff + new int[]{-1}, // stateMixedIndexOff + new int[0], // edgeNextState + new int[0], // edgeLabelOff + new short[0], // edgeLabelLen + new long[0], // edgeLiteralPrefix + new byte[0], // edgeKind + new int[0], // edgeMixedChunkOff + new short[0], // edgeMixedChunkCount + new int[0], // edgeMixedParamOff + new short[0], // edgeMixedParamCount + new byte[0], // edgeMixedStrategy + new int[0], // mixedChunkOff + new short[0], // mixedChunkLen + new short[0], // mixedParamKeyId + new int[0], // acceptHandlerId + new int[0], // acceptLabelId + new int[0], // acceptRouteId + new int[0], // indexStart + new short[0], // indexCount + new int[0], // indexSecondOff + new long[0], // literalHashKey + new int[0] // literalHashEdge + ); + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/RouteGraph.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/RouteGraph.java new file mode 100644 index 0000000..7d1b3d5 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/RouteGraph.java @@ -0,0 +1,558 @@ +package dev.relism.fpr.core.internal.compile; + +import dev.relism.fpr.core.RoutePattern; +import dev.relism.fpr.core.RouterBuilder; +import dev.relism.fpr.core.internal.runtime.EdgeKind; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +final class RouteGraph { + final List> nodes; + final List handlers; + + private RouteGraph(List> nodes, List handlers) { + this.nodes = nodes; + this.handlers = handlers; + } + + static RouteGraph build(List> routes, Map paramIds) { + List> ordered = new ArrayList<>(routes); + ordered.sort(routeComparator()); + + List> nodes = new ArrayList<>(); + Node root = new Node<>(0); + nodes.add(root); + + List handlers = new ArrayList<>(); + int routeId = 0; + + for (RouterBuilder.RouteSpec spec : ordered) { + int handlerId = handlers.size(); + handlers.add(spec.handler()); + Node current = root; + List segments = spec.pattern().segments(); + for (int i = 0; i < segments.size(); i++) { + RoutePattern.Segment segment = segments.get(i); + boolean last = i == segments.size() - 1; + switch (segment.type()) { + case LITERAL: + current = current.literal(nodes, literalBytes((RoutePattern.Literal) segment)); + break; + case MIXED: + current = current.mixed(nodes, mixedDef((RoutePattern.Mixed) segment, paramIds)); + break; + case PARAM: + int paramId = paramId(((RoutePattern.Param) segment).name(), paramIds); + current = current.param(nodes, paramId); + break; + case WILDCARD: + current = current.wild(nodes); + break; + case CATCH_ALL: + if (!last) { + throw new IllegalArgumentException("catch-all must be last segment"); + } + Integer catchId = catchId((RoutePattern.CatchAll) segment, paramIds); + current = current.catchAll(nodes, catchId); + break; + default: + throw new IllegalStateException("Unhandled segment type: " + segment.type()); + } + } + current.accept(spec.labelId(), handlerId, routeId); + routeId++; + } + + return new RouteGraph<>(nodes, handlers); + } + + RouteGraph canonicalize() { + int size = nodes.size(); + int[] remap = new int[size]; + Arrays.fill(remap, -1); + Map canonical = new HashMap<>(); + List> canonNodes = new ArrayList<>(); + + for (int i = size - 1; i >= 0; i--) { + Node node = nodes.get(i); + NodeKey key = node.key(remap); + Integer existing = canonical.get(key); + if (existing != null) { + remap[node.id] = existing; + } else { + int id = canonNodes.size(); + remap[node.id] = id; + canonical.put(key, id); + canonNodes.add(node); + } + } + + for (Node node : canonNodes) { + node.remap(remap); + } + int rootIndex = remap[0]; + if (rootIndex != 0) { + int[] reorder = new int[canonNodes.size()]; + Arrays.fill(reorder, -1); + reorder[rootIndex] = 0; + int next = 1; + for (int i = 0; i < canonNodes.size(); i++) { + if (i == rootIndex) { + continue; + } + reorder[i] = next++; + } + List> reordered = new ArrayList<>(canonNodes.size()); + for (int i = 0; i < canonNodes.size(); i++) { + reordered.add(null); + } + for (int i = 0; i < canonNodes.size(); i++) { + reordered.set(reorder[i], canonNodes.get(i)); + } + for (Node node : reordered) { + node.remap(reorder); + } + return new RouteGraph<>(reordered, handlers); + } + + return new RouteGraph<>(canonNodes, handlers); + } + + private static int paramId(String name, Map paramIds) { + Integer id = paramIds.get(name); + if (id == null) { + throw new IllegalArgumentException("Unknown param id: " + name); + } + return id; + } + + private static Integer catchId(RoutePattern.CatchAll segment, Map paramIds) { + String name = segment.name(); + if (name == null || name.isEmpty()) { + return null; + } + return paramId(name, paramIds); + } + + private static byte[] literalBytes(RoutePattern.Literal literal) { + return literal.text().getBytes(StandardCharsets.UTF_8); + } + + private static MixedDef mixedDef(RoutePattern.Mixed mixed, Map paramIds) { + String[] literals = mixed.literals(); + String[] params = mixed.params(); + byte[][] literalBytes = new byte[literals.length][]; + for (int i = 0; i < literals.length; i++) { + literalBytes[i] = literals[i].getBytes(StandardCharsets.UTF_8); + } + short[] keys = new short[params.length]; + for (int i = 0; i < params.length; i++) { + keys[i] = (short) paramId(params[i], paramIds); + } + return new MixedDef(literalBytes, keys); + } + + private static Comparator> routeComparator() { + return (a, b) -> { + List segA = a.pattern().segments(); + List segB = b.pattern().segments(); + int min = Math.min(segA.size(), segB.size()); + for (int i = 0; i < min; i++) { + int rankA = rank(segA.get(i).type()); + int rankB = rank(segB.get(i).type()); + if (rankA != rankB) { + return Integer.compare(rankB, rankA); + } + } + if (segA.size() != segB.size()) { + return Integer.compare(segB.size(), segA.size()); + } + return Integer.compare(a.order(), b.order()); + }; + } + + private static int rank(RoutePattern.SegmentType type) { + switch (type) { + case LITERAL: + return 5; + case MIXED: + return 4; + case PARAM: + return 3; + case WILDCARD: + return 2; + case CATCH_ALL: + return 1; + default: + return 0; + } + } + + static final class MixedDef { + final byte[][] literals; + final short[] paramKeys; + + MixedDef(byte[][] literals, short[] paramKeys) { + this.literals = literals; + this.paramKeys = paramKeys; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof MixedDef)) { + return false; + } + MixedDef other = (MixedDef) obj; + return Arrays.deepEquals(literals, other.literals) && Arrays.equals(paramKeys, other.paramKeys); + } + + @Override + public int hashCode() { + int h = Arrays.deepHashCode(literals); + h = 31 * h + Arrays.hashCode(paramKeys); + return h; + } + } + + static final class Accept { + final int labelId; + final int handlerId; + final int routeId; + + Accept(int labelId, int handlerId, int routeId) { + this.labelId = labelId; + this.handlerId = handlerId; + this.routeId = routeId; + } + } + + static final class Edge { + final EdgeKind kind; + final byte[] literal; + final MixedDef mixed; + int nextState; + + Edge(EdgeKind kind, byte[] literal, MixedDef mixed, int nextState) { + this.kind = kind; + this.literal = literal; + this.mixed = mixed; + this.nextState = nextState; + } + + static Comparator literalComparator() { + return (a, b) -> { + if (a.literal.length != b.literal.length) { + return Integer.compare(a.literal.length, b.literal.length); + } + long aKey = literalPrefix(a.literal); + long bKey = literalPrefix(b.literal); + int cmp = Long.compareUnsigned(aKey, bKey); + if (cmp != 0) { + return cmp; + } + return Arrays.compare(a.literal, b.literal); + }; + } + + static Comparator mixedComparator() { + return (a, b) -> { + int aTotal = totalLiteralLen(a.mixed.literals); + int bTotal = totalLiteralLen(b.mixed.literals); + if (aTotal != bTotal) { + return Integer.compare(bTotal, aTotal); + } + + byte[] aFirst = a.mixed.literals[0]; + byte[] bFirst = b.mixed.literals[0]; + int aLen = aFirst.length; + int bLen = bFirst.length; + if (aLen == 0 && bLen != 0) { + return 1; + } + if (aLen != 0 && bLen == 0) { + return -1; + } + if (aLen != bLen) { + return Integer.compare(bLen, aLen); + } + int cmp = Arrays.compare(aFirst, bFirst); + if (cmp != 0) { + return cmp; + } + int count = Math.min(a.mixed.literals.length, b.mixed.literals.length); + for (int i = 1; i < count; i++) { + cmp = Arrays.compare(a.mixed.literals[i], b.mixed.literals[i]); + if (cmp != 0) { + return cmp; + } + } + return Integer.compare(b.mixed.literals.length, a.mixed.literals.length); + }; + } + + private static int totalLiteralLen(byte[][] literals) { + int total = 0; + for (byte[] literal : literals) { + total += literal.length; + } + return total; + } + } + + private static long literalPrefix(byte[] literal) { + int len = Math.min(8, literal.length); + long key = 0; + for (int i = 0; i < len; i++) { + key |= ((long) literal[i] & 0xFFL) << (i * 8); + } + return key; + } + + static final class Node { + final int id; + final List edges = new ArrayList<>(); + int paramNext = -1; + short paramKeyId = -1; + int catchAllNext = -1; + short catchAllKeyId = -1; + final List accepts = new ArrayList<>(); + + Node(int id) { + this.id = id; + } + + Node literal(List> nodes, byte[] literal) { + for (Edge edge : edges) { + if (edge.kind == EdgeKind.LITERAL && Arrays.equals(edge.literal, literal)) { + return nodes.get(edge.nextState); + } + } + Node next = new Node<>(nodes.size()); + nodes.add(next); + edges.add(new Edge(EdgeKind.LITERAL, literal, null, next.id)); + return next; + } + + Node mixed(List> nodes, MixedDef def) { + for (Edge edge : edges) { + if (edge.kind == EdgeKind.MIXED && mixedEquals(edge.mixed, def)) { + if (!Arrays.equals(edge.mixed.paramKeys, def.paramKeys)) { + throw new IllegalArgumentException("Ambiguous mixed segment: conflicting param keys"); + } + return nodes.get(edge.nextState); + } + } + Node next = new Node<>(nodes.size()); + nodes.add(next); + edges.add(new Edge(EdgeKind.MIXED, null, def, next.id)); + return next; + } + + Node param(List> nodes, int keyId) { + if (paramNext != -1) { + if (paramKeyId != (short) keyId) { + throw new IllegalArgumentException("Ambiguous param segment at state " + id); + } + return nodes.get(paramNext); + } + Node next = new Node<>(nodes.size()); + nodes.add(next); + paramNext = next.id; + paramKeyId = (short) keyId; + return next; + } + + Node wild(List> nodes) { + for (Edge edge : edges) { + if (edge.kind == EdgeKind.WILD) { + return nodes.get(edge.nextState); + } + } + Node next = new Node<>(nodes.size()); + nodes.add(next); + edges.add(new Edge(EdgeKind.WILD, null, null, next.id)); + return next; + } + + Node catchAll(List> nodes, Integer keyId) { + if (catchAllNext != -1) { + short existing = catchAllKeyId; + short incoming = keyId == null ? -1 : keyId.shortValue(); + if (existing != incoming) { + throw new IllegalArgumentException("Ambiguous catch-all segment at state " + id); + } + return nodes.get(catchAllNext); + } + Node next = new Node<>(nodes.size()); + nodes.add(next); + catchAllNext = next.id; + catchAllKeyId = keyId == null ? (short) -1 : keyId.shortValue(); + return next; + } + + void accept(int labelId, int handlerId, int routeId) { + for (Accept accept : accepts) { + if (accept.labelId == labelId) { + throw new IllegalArgumentException("Ambiguous route: duplicate pattern for label " + labelId); + } + } + accepts.add(new Accept(labelId, handlerId, routeId)); + } + + List literalEdges() { + List out = new ArrayList<>(); + for (Edge edge : edges) { + if (edge.kind == EdgeKind.LITERAL) { + out.add(edge); + } + } + return out; + } + + List mixedEdges() { + List out = new ArrayList<>(); + for (Edge edge : edges) { + if (edge.kind == EdgeKind.MIXED) { + out.add(edge); + } + } + return out; + } + + Edge wildEdge() { + for (Edge edge : edges) { + if (edge.kind == EdgeKind.WILD) { + return edge; + } + } + return null; + } + + NodeKey key(int[] remap) { + return new NodeKey(this, remap); + } + + void remap(int[] remap) { + for (Edge edge : edges) { + edge.nextState = remap[edge.nextState]; + } + if (paramNext != -1) { + paramNext = remap[paramNext]; + } + if (catchAllNext != -1) { + catchAllNext = remap[catchAllNext]; + } + } + } + + private static final class NodeKey { + private final int hash; + private final EdgeKind[] edgeKinds; + private final int[] edgeNext; + private final byte[][] edgeLiterals; + private final MixedDef[] edgeMixed; + private final int paramNext; + private final short paramKeyId; + private final int catchNext; + private final short catchKeyId; + private final int[] acceptMethods; + private final int[] acceptHandlers; + private final int[] acceptRoutes; + + private NodeKey(Node node, int[] remap) { + this.paramNext = node.paramNext == -1 ? -1 : remap[node.paramNext]; + this.paramKeyId = node.paramKeyId; + this.catchNext = node.catchAllNext == -1 ? -1 : remap[node.catchAllNext]; + this.catchKeyId = node.catchAllKeyId; + + int edgeCount = node.edges.size(); + this.edgeKinds = new EdgeKind[edgeCount]; + this.edgeNext = new int[edgeCount]; + this.edgeLiterals = new byte[edgeCount][]; + this.edgeMixed = new MixedDef[edgeCount]; + for (int i = 0; i < edgeCount; i++) { + Edge edge = node.edges.get(i); + edgeKinds[i] = edge.kind; + edgeNext[i] = remap[edge.nextState]; + edgeLiterals[i] = edge.literal; + edgeMixed[i] = edge.mixed; + } + + int acceptCount = node.accepts.size(); + acceptMethods = new int[acceptCount]; + acceptHandlers = new int[acceptCount]; + acceptRoutes = new int[acceptCount]; + for (int i = 0; i < acceptCount; i++) { + Accept accept = node.accepts.get(i); + acceptMethods[i] = accept.labelId; + acceptHandlers[i] = accept.handlerId; + acceptRoutes[i] = accept.routeId; + } + + this.hash = computeHash(); + } + + private int computeHash() { + int h = 1; + h = 31 * h + paramNext; + h = 31 * h + paramKeyId; + h = 31 * h + catchNext; + h = 31 * h + catchKeyId; + h = 31 * h + Arrays.hashCode(edgeKinds); + h = 31 * h + Arrays.hashCode(edgeNext); + h = 31 * h + Arrays.deepHashCode(edgeLiterals); + h = 31 * h + Arrays.deepHashCode(edgeMixed); + h = 31 * h + Arrays.hashCode(acceptMethods); + h = 31 * h + Arrays.hashCode(acceptHandlers); + h = 31 * h + Arrays.hashCode(acceptRoutes); + return h; + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof NodeKey)) { + return false; + } + NodeKey other = (NodeKey) obj; + if (paramNext != other.paramNext || paramKeyId != other.paramKeyId) { + return false; + } + if (catchNext != other.catchNext || catchKeyId != other.catchKeyId) { + return false; + } + if (!Arrays.equals(edgeKinds, other.edgeKinds) || !Arrays.equals(edgeNext, other.edgeNext)) { + return false; + } + if (!Arrays.deepEquals(edgeLiterals, other.edgeLiterals)) { + return false; + } + if (!Arrays.deepEquals(edgeMixed, other.edgeMixed)) { + return false; + } + return Arrays.equals(acceptMethods, other.acceptMethods) + && Arrays.equals(acceptHandlers, other.acceptHandlers) + && Arrays.equals(acceptRoutes, other.acceptRoutes); + } + } + + private static boolean mixedEquals(MixedDef a, MixedDef b) { + return Arrays.deepEquals(a.literals, b.literals); + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/lookup/LiteralLookupPlanBuilder.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/lookup/LiteralLookupPlanBuilder.java new file mode 100644 index 0000000..d6733e0 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/lookup/LiteralLookupPlanBuilder.java @@ -0,0 +1,86 @@ +package dev.relism.fpr.core.internal.compile.lookup; + +import dev.relism.fpr.core.internal.compile.PrimitiveBuilders; +import dev.relism.fpr.core.internal.runtime.lookup.LiteralLookupStrategy; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LiteralLookupPlanBuilder { + private static final int LINEAR_LIMIT = 16; + private static final int ORDERED_LIMIT = 256; + + public static byte selectStrategy(int literalCount) { + if (literalCount <= LINEAR_LIMIT) { + return LiteralLookupStrategy.LINEAR; + } + if (literalCount <= ORDERED_LIMIT) { + return LiteralLookupStrategy.ORDERED_PREFIX; + } + return LiteralLookupStrategy.HASH; + } + + public static long prefixKey(byte[] literal) { + int len = Math.min(8, literal.length); + long key = 0; + for (int i = 0; i < len; i++) { + key |= ((long) literal[i] & 0xFFL) << (i * 8); + } + return key; + } + + public static HashPlan buildHash(PrimitiveBuilders.LongArrayList hashKeys, + PrimitiveBuilders.IntArrayList hashEdges, + PrimitiveBuilders.LongArrayList edgePrefix, + PrimitiveBuilders.ShortArrayList edgeLabelLen, + int start, + int count) { + int size = tableSize(count); + int mask = size - 1; + int off = hashKeys.size(); + for (int i = 0; i < size; i++) { + hashKeys.add(0L); + hashEdges.add(-1); + } + int end = start + count; + for (int i = start; i < end; i++) { + int len = edgeLabelLen.get(i); + if (len <= 0) { + continue; + } + long key = edgePrefix.get(i); + int slot = mix(key) & mask; + while (hashKeys.get(off + slot) != 0L) { + slot = (slot + 1) & mask; + } + hashKeys.set(off + slot, key); + hashEdges.set(off + slot, i); + } + return new HashPlan(off, mask); + } + + private static int tableSize(int count) { + int size = 1; + int target = Math.max(4, count * 2); + while (size < target) { + size <<= 1; + } + return size; + } + + private static int mix(long key) { + long h = key ^ (key >>> 33); + h *= 0xff51afd7ed558ccdL; + h ^= (h >>> 33); + h *= 0xc4ceb9fe1a85ec53L; + h ^= (h >>> 33); + return (int) h; + } + + @RequiredArgsConstructor(access = AccessLevel.PRIVATE) + public static final class HashPlan { + public final int offset; + public final int mask; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/lookup/MixedLookupPlanBuilder.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/lookup/MixedLookupPlanBuilder.java new file mode 100644 index 0000000..fa165f4 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/compile/lookup/MixedLookupPlanBuilder.java @@ -0,0 +1,18 @@ +package dev.relism.fpr.core.internal.compile.lookup; + +import dev.relism.fpr.core.internal.runtime.lookup.MixedLookupStrategy; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MixedLookupPlanBuilder { + public static byte strategyForParamCount(int paramCount) { + if (paramCount <= 1) { + return MixedLookupStrategy.ONE; + } + if (paramCount == 2) { + return MixedLookupStrategy.TWO; + } + return MixedLookupStrategy.N; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/ByteCompare.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/ByteCompare.java new file mode 100644 index 0000000..856a717 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/ByteCompare.java @@ -0,0 +1,53 @@ +package dev.relism.fpr.core.internal.runtime; + +import dev.relism.fpr.core.ByteView; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.nio.ByteOrder; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ByteCompare { + private static final VarHandle LONG_VIEW = MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN); + + public static boolean equals(ByteView view, int start, byte[] blob, int off, int len, boolean supportsLong) { + if (len == 0) { + return true; + } + int i = 0; + if (supportsLong && len >= 8) { + int end = len - 8; + for (; i <= end; i += 8) { + long a = view.longAt(start + i); + long b = (long) LONG_VIEW.get(blob, off + i); + if (a != b) { + return false; + } + } + } + for (; i < len; i++) { + if (view.byteAt(start + i) != blob[off + i]) { + return false; + } + } + return true; + } + + public static int indexOf(ByteView view, int start, int max, byte[] blob, int off, int len, boolean supportsLong) { + if (len == 0) { + return start; + } + byte first = blob[off]; + for (int i = start; i <= max; i++) { + if (view.byteAt(i) != first) { + continue; + } + if (equals(view, i, blob, off, len, supportsLong)) { + return i; + } + } + return -1; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/EdgeDispatch.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/EdgeDispatch.java new file mode 100644 index 0000000..5373b83 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/EdgeDispatch.java @@ -0,0 +1,35 @@ +package dev.relism.fpr.core.internal.runtime; + +final class EdgeDispatch { + private EdgeDispatch() { + } + + static long mixedPrefixRange(FrozenRouter router, int state, int firstByte) { + int prefixCount = router.stateMixedPrefixCount[state]; + if (prefixCount <= 0) { + return range(0, 0); + } + int indexOff = router.stateMixedIndexOff[state]; + if (indexOff >= 0) { + int rangeStartRel = router.indexStart[indexOff + firstByte]; + int rangeCount = router.indexCount[indexOff + firstByte]; + if (rangeCount == 0 || rangeStartRel < 0) { + return range(0, 0); + } + return range(router.stateMixedStart[state] + rangeStartRel, rangeCount); + } + return range(router.stateMixedStart[state], router.stateMixedCount[state]); + } + + static long range(int start, int count) { + return ((long) start << 32) | (count & 0xffffffffL); + } + + static int rangeStart(long range) { + return (int) (range >>> 32); + } + + static int rangeCount(long range) { + return (int) range; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/EdgeKind.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/EdgeKind.java new file mode 100644 index 0000000..90b9183 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/EdgeKind.java @@ -0,0 +1,9 @@ +package dev.relism.fpr.core.internal.runtime; + +public enum EdgeKind { + LITERAL, + MIXED, + PARAM, + WILD, + CATCH +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/FrozenRouter.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/FrozenRouter.java new file mode 100644 index 0000000..a387d8d --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/FrozenRouter.java @@ -0,0 +1,250 @@ +package dev.relism.fpr.core.internal.runtime; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.FastPathRouter; +import dev.relism.fpr.core.MatchResult; +import dev.relism.fpr.core.internal.runtime.lookup.MixedLookup; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public final class FrozenRouter implements FastPathRouter { + public final byte[] blob; + public final H[] handlers; + + public final int[] stateFirstEdge; + public final short[] stateEdgeCount; + public final int[] stateLiteralStart; + public final short[] stateLiteralCount; + public final byte[] stateLiteralStrategy; + public final int[] stateLiteralHashOff; + public final int[] stateLiteralHashMask; + public final int[] stateMixedStart; + public final short[] stateMixedCount; + public final short[] stateMixedPrefixCount; + public final int[] stateWildIndex; + + public final int[] stateParamNext; + public final short[] stateParamKeyId; + public final int[] stateCatchAllNext; + public final short[] stateCatchAllKeyId; + + public final int[] stateAcceptFirst; + public final short[] stateAcceptCount; + + public final int[] stateLiteralIndexOff; + public final int[] stateMixedIndexOff; + + public final int[] edgeNextState; + public final int[] edgeLabelOff; + public final short[] edgeLabelLen; + public final long[] edgeLiteralPrefix; + public final byte[] edgeKind; + public final int[] edgeMixedChunkOff; + public final short[] edgeMixedChunkCount; + public final int[] edgeMixedParamOff; + public final short[] edgeMixedParamCount; + public final byte[] edgeMixedStrategy; + + public final int[] mixedChunkOff; + public final short[] mixedChunkLen; + public final short[] mixedParamKeyId; + + public final int[] acceptHandlerId; + public final int[] acceptLabelId; + public final int[] acceptRouteId; + + public final int[] indexStart; + public final short[] indexCount; + public final int[] indexSecondOff; + public final long[] literalHashKey; + public final int[] literalHashEdge; + + /** + * Matches without allocations, using the provided reusable MatchResult buffer. + */ + @Override + public int match(ByteView input, MatchResult out) { + RouteSearch search = new RouteSearch(); + SegmentCursor cursor = new SegmentCursor(); + out.reset(); + boolean supportsLong = input.supportsLong(); + cursor.reset(input); + search.reset(out); + RouteSearch.Frame frame = search.frame(); + + int len = cursor.length(); + int state = 0; + boolean hasSegment = cursor.advance(); + + final byte kindLiteral = (byte) EdgeKind.LITERAL.ordinal(); + final byte kindMixed = (byte) EdgeKind.MIXED.ordinal(); + final byte kindParam = (byte) EdgeKind.PARAM.ordinal(); + final byte kindWild = (byte) EdgeKind.WILD.ordinal(); + final byte kindCatch = (byte) EdgeKind.CATCH.ordinal(); + + while (true) { + if (!hasSegment) { + int accept = accept(state, out); + if (accept != NO_MATCH) { + return accept; + } + if (stateCatchAllNext[state] != -1) { + short key = stateCatchAllKeyId[state]; + if (key >= 0) { + out.addParam(key, len, 0); + } + return accept(stateCatchAllNext[state], out); + } + long backtracked = backtrack(frame, input, supportsLong, out, + kindLiteral, kindMixed, kindParam, kindWild, kindCatch, len, search, cursor); + if (backtracked == BACKTRACK_NO_MATCH) { + return NO_MATCH; + } + if ((backtracked & BACKTRACK_ACCEPT_MASK) != 0) { + return (int) backtracked; + } + state = (int) backtracked; + hasSegment = cursor.advance(); + continue; + } + + int segStart = cursor.segStart(); + int segLen = cursor.segLen(); + + if (segLen <= 0) { + long backtracked = backtrack(frame, input, supportsLong, out, + kindLiteral, kindMixed, kindParam, kindWild, kindCatch, len, search, cursor); + if (backtracked == BACKTRACK_NO_MATCH) { + return NO_MATCH; + } + if ((backtracked & BACKTRACK_ACCEPT_MASK) != 0) { + return (int) backtracked; + } + state = (int) backtracked; + hasSegment = cursor.advance(); + continue; + } + + long candidate = search.selectCandidate(this, state, input, cursor, supportsLong, out); + if (candidate == RouteSearch.NO_CANDIDATE) { + long backtracked = backtrack(frame, input, supportsLong, out, + kindLiteral, kindMixed, kindParam, kindWild, kindCatch, len, search, cursor); + if (backtracked == BACKTRACK_NO_MATCH) { + return NO_MATCH; + } + if ((backtracked & BACKTRACK_ACCEPT_MASK) != 0) { + return (int) backtracked; + } + state = (int) backtracked; + hasSegment = cursor.advance(); + continue; + } + + byte kind = RouteSearch.kind(candidate); + int edgeIndex = RouteSearch.edgeIndex(candidate); + + if (kind == kindLiteral) { + state = edgeNextState[edgeIndex]; + } else if (kind == kindMixed) { + state = edgeNextState[edgeIndex]; + } else if (kind == kindParam) { + out.addParam(stateParamKeyId[state], segStart, segLen); + state = stateParamNext[state]; + } else if (kind == kindWild) { + state = edgeNextState[edgeIndex]; + } else if (kind == kindCatch) { + short key = stateCatchAllKeyId[state]; + if (key >= 0) { + out.addParam(key, segStart, len - segStart); + } + return accept(stateCatchAllNext[state], out); + } else { + state = NO_MATCH; + } + + if (state == NO_MATCH) { + long backtracked = backtrack(frame, input, supportsLong, out, + kindLiteral, kindMixed, kindParam, kindWild, kindCatch, len, search, cursor); + if (backtracked == BACKTRACK_NO_MATCH) { + return NO_MATCH; + } + if ((backtracked & BACKTRACK_ACCEPT_MASK) != 0) { + return (int) backtracked; + } + state = (int) backtracked; + hasSegment = cursor.advance(); + continue; + } + + hasSegment = cursor.advance(); + } + } + + private long backtrack(RouteSearch.Frame frame, + ByteView input, + boolean supportsLong, + MatchResult out, + byte kindLiteral, + byte kindMixed, + byte kindParam, + byte kindWild, + byte kindCatch, + int len, + RouteSearch search, + SegmentCursor cursor) { + while (search.popInto(frame)) { + out.rollbackTo(frame.paramMark); + cursor.restore(frame.segStart, frame.segLen, frame.nextIdx); + if (frame.kind == kindCatch) { + int catchNext = stateCatchAllNext[frame.state]; + if (catchNext == -1) { + continue; + } + short key = stateCatchAllKeyId[frame.state]; + if (key >= 0) { + out.addParam(key, frame.segStart, len - frame.segStart); + } + return BACKTRACK_ACCEPT_MASK | (accept(catchNext, out) & 0xffffffffL); + } + int next; + if (frame.kind == kindLiteral) { + next = edgeNextState[frame.edgeIndex]; + } else if (frame.kind == kindMixed) { + if (!MixedLookup.match(this, frame.edgeIndex, input, frame.segStart, frame.segLen, supportsLong, out)) { + continue; + } + next = edgeNextState[frame.edgeIndex]; + } else if (frame.kind == kindParam) { + out.addParam(stateParamKeyId[frame.state], frame.segStart, frame.segLen); + next = stateParamNext[frame.state]; + } else if (frame.kind == kindWild) { + next = edgeNextState[frame.edgeIndex]; + } else { + continue; + } + if (next >= 0) { + return next; + } + } + return BACKTRACK_NO_MATCH; + } + + int accept(int state, MatchResult out) { + int labelId = out.labelId(); + int start = stateAcceptFirst[state]; + int count = stateAcceptCount[state]; + for (int i = 0; i < count; i++) { + int idx = start + i; + int acceptLabel = acceptLabelId[idx]; + if (acceptLabel == 0 || acceptLabel == labelId) { + out.setHandler(handlers[acceptHandlerId[idx]]); + return acceptRouteId[idx]; + } + } + return NO_MATCH; + } + + private static final long BACKTRACK_ACCEPT_MASK = 0x4000000000000000L; + private static final long BACKTRACK_NO_MATCH = -1L; + private static final int NO_MATCH = FastPathRouter.NO_MATCH; +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/RouteSearch.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/RouteSearch.java new file mode 100644 index 0000000..d96d145 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/RouteSearch.java @@ -0,0 +1,245 @@ +package dev.relism.fpr.core.internal.runtime; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.MatchResult; +import dev.relism.fpr.core.MatchResultAccess; +import dev.relism.fpr.core.internal.runtime.lookup.LiteralLookup; +import dev.relism.fpr.core.internal.runtime.lookup.MixedLookup; + +final class RouteSearch { + static final long NO_CANDIDATE = -1L; + + private int[] stackState; + private int[] stackSegStart; + private int[] stackSegLen; + private int[] stackNextIdx; + private int[] stackParamMark; + private int[] stackEdgeIndex; + private byte[] stackKind; + private int[] keyIds; + private int[] starts; + private int[] lens; + private int[] scratchKeyIds; + private int[] scratchStarts; + private int[] scratchLens; + private int[] scratchEdges; + private int stackSize; + private final Frame frame = new Frame(); + + private final byte kindLiteral = (byte) EdgeKind.LITERAL.ordinal(); + private final byte kindMixed = (byte) EdgeKind.MIXED.ordinal(); + private final byte kindParam = (byte) EdgeKind.PARAM.ordinal(); + private final byte kindWild = (byte) EdgeKind.WILD.ordinal(); + private final byte kindCatch = (byte) EdgeKind.CATCH.ordinal(); + + void reset(MatchResult out) { + this.stackState = MatchResultAccess.stackState(out); + this.stackSegStart = MatchResultAccess.stackSegStart(out); + this.stackSegLen = MatchResultAccess.stackSegLen(out); + this.stackNextIdx = MatchResultAccess.stackNextIdx(out); + this.stackParamMark = MatchResultAccess.stackParamMark(out); + this.stackEdgeIndex = MatchResultAccess.stackEdgeIndex(out); + this.stackKind = MatchResultAccess.stackKind(out); + this.keyIds = MatchResultAccess.keyIds(out); + this.starts = MatchResultAccess.starts(out); + this.lens = MatchResultAccess.lens(out); + this.scratchKeyIds = MatchResultAccess.scratchKeyIds(out); + this.scratchStarts = MatchResultAccess.scratchStarts(out); + this.scratchLens = MatchResultAccess.scratchLens(out); + this.scratchEdges = MatchResultAccess.scratchEdges(out); + this.stackSize = 0; + } + + Frame frame() { + return frame; + } + + long selectCandidate(FrozenRouter router, + int state, + ByteView input, + SegmentCursor cursor, + boolean supportsLong, + MatchResult out) { + int segStart = cursor.segStart(); + int segLen = cursor.segLen(); + int nextIdx = cursor.nextIdx(); + int mark = out.mark(); + + int literalEdge = LiteralLookup.find(router, input, segStart, segLen, supportsLong, state); + + boolean keepMixedParams = literalEdge == -1; + int mixedCount = router.stateMixedCount[state]; + int mixedStart = router.stateMixedStart[state]; + int mixedEnd = mixedStart + mixedCount; + int mixedMatchCount = 0; + int mixedParamCount = 0; + if (mixedCount > 0) { + int firstByte = input.byteAt(segStart) & 0xFF; + long prefixRange = EdgeDispatch.mixedPrefixRange(router, state, firstByte); + int rangeStart = EdgeDispatch.rangeStart(prefixRange); + int rangeCount = EdgeDispatch.rangeCount(prefixRange); + if (rangeCount > 0) { + int end = rangeStart + rangeCount; + for (int i = rangeStart; i < end; i++) { + if (router.edgeLabelLen[i] == 0) { + continue; + } + if (MixedLookup.match(router, i, input, segStart, segLen, supportsLong, out)) { + if (mixedMatchCount >= scratchEdges.length) { + throw new IllegalStateException("MatchResult stack exhausted"); + } + scratchEdges[mixedMatchCount++] = i; + if (mixedMatchCount == 1 && keepMixedParams) { + mixedParamCount = out.paramCount() - mark; + if (mixedParamCount > 0) { + System.arraycopy(keyIds, mark, scratchKeyIds, 0, mixedParamCount); + System.arraycopy(starts, mark, scratchStarts, 0, mixedParamCount); + System.arraycopy(lens, mark, scratchLens, 0, mixedParamCount); + } + } + out.rollbackTo(mark); + } + } + } + if (mixedCount > router.stateMixedPrefixCount[state]) { + for (int i = mixedStart; i < mixedEnd; i++) { + if (router.edgeLabelLen[i] != 0) { + continue; + } + if (MixedLookup.match(router, i, input, segStart, segLen, supportsLong, out)) { + if (mixedMatchCount >= scratchEdges.length) { + throw new IllegalStateException("MatchResult stack exhausted"); + } + scratchEdges[mixedMatchCount++] = i; + if (mixedMatchCount == 1 && keepMixedParams) { + mixedParamCount = out.paramCount() - mark; + if (mixedParamCount > 0) { + System.arraycopy(keyIds, mark, scratchKeyIds, 0, mixedParamCount); + System.arraycopy(starts, mark, scratchStarts, 0, mixedParamCount); + System.arraycopy(lens, mark, scratchLens, 0, mixedParamCount); + } + } + out.rollbackTo(mark); + } + } + } + } + + int mixedFirst = mixedMatchCount > 0 ? scratchEdges[0] : -1; + int paramNext = router.stateParamNext[state]; + int wildIndex = router.stateWildIndex[state]; + int catchNext = router.stateCatchAllNext[state]; + + byte kind = -1; + int edgeIndex = -1; + if (literalEdge != -1) { + kind = kindLiteral; + edgeIndex = literalEdge; + } else if (mixedFirst != -1) { + kind = kindMixed; + edgeIndex = mixedFirst; + } else if (paramNext != -1) { + kind = kindParam; + } else if (wildIndex != -1) { + kind = kindWild; + edgeIndex = wildIndex; + } else if (catchNext != -1) { + kind = kindCatch; + } + + if (kind == -1) { + return NO_CANDIDATE; + } + + if (catchNext != -1 && kind != kindCatch) { + push(kindCatch, state, -1, segStart, segLen, nextIdx, mark); + } + if (wildIndex != -1 && kind != kindWild) { + push(kindWild, state, wildIndex, segStart, segLen, nextIdx, mark); + } + if (paramNext != -1 && kind != kindParam) { + push(kindParam, state, -1, segStart, segLen, nextIdx, mark); + } + if (mixedMatchCount > 0 && (kind != kindMixed || mixedMatchCount > 1)) { + for (int i = mixedMatchCount - 1; i >= 0; i--) { + if (kind == kindMixed && i == 0) { + continue; + } + push(kindMixed, state, scratchEdges[i], segStart, segLen, nextIdx, mark); + } + } + + if (kind == kindMixed) { + out.rollbackTo(mark); + if (keepMixedParams) { + if (mixedParamCount > 0) { + System.arraycopy(scratchKeyIds, 0, keyIds, mark, mixedParamCount); + System.arraycopy(scratchStarts, 0, starts, mark, mixedParamCount); + System.arraycopy(scratchLens, 0, lens, mark, mixedParamCount); + } + MatchResultAccess.paramCount(out, mark + mixedParamCount); + } + } else { + out.rollbackTo(mark); + } + + return pack(kind, edgeIndex); + } + + boolean popInto(Frame out) { + if (stackSize == 0) { + return false; + } + int idx = --stackSize; + out.state = stackState[idx]; + out.segStart = stackSegStart[idx]; + out.segLen = stackSegLen[idx]; + out.nextIdx = stackNextIdx[idx]; + out.paramMark = stackParamMark[idx]; + out.edgeIndex = stackEdgeIndex[idx]; + out.kind = stackKind[idx]; + return true; + } + + private void push(byte kind, + int state, + int edgeIndex, + int segStart, + int segLen, + int nextIdx, + int paramMark) { + if (stackSize >= stackState.length) { + throw new IllegalStateException("MatchResult stack exhausted"); + } + stackState[stackSize] = state; + stackSegStart[stackSize] = segStart; + stackSegLen[stackSize] = segLen; + stackNextIdx[stackSize] = nextIdx; + stackParamMark[stackSize] = paramMark; + stackEdgeIndex[stackSize] = edgeIndex; + stackKind[stackSize] = kind; + stackSize++; + } + + private static long pack(byte kind, int edgeIndex) { + return ((long) kind << 32) | (edgeIndex & 0xffffffffL); + } + + static byte kind(long packed) { + return (byte) (packed >>> 32); + } + + static int edgeIndex(long packed) { + return (int) packed; + } + + static final class Frame { + int state; + int segStart; + int segLen; + int nextIdx; + int paramMark; + int edgeIndex; + byte kind; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/SegmentCursor.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/SegmentCursor.java new file mode 100644 index 0000000..a5fadc8 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/SegmentCursor.java @@ -0,0 +1,64 @@ +package dev.relism.fpr.core.internal.runtime; + +import dev.relism.fpr.core.ByteView; + +final class SegmentCursor { + private ByteView input; + private int len; + private int idx; + private int segStart; + private int segLen; + private int nextIdx; + + void reset(ByteView input) { + this.input = input; + this.len = input.length(); + this.idx = 0; + if (idx < len && input.byteAt(idx) == '/') { + idx++; + } + this.segStart = 0; + this.segLen = -1; + this.nextIdx = idx; + } + + boolean advance() { + if (idx >= len) { + return false; + } + segStart = idx; + while (idx < len && input.byteAt(idx) != '/') { + idx++; + } + segLen = idx - segStart; + nextIdx = idx; + if (idx < len && input.byteAt(idx) == '/') { + nextIdx = idx + 1; + } + idx = nextIdx; + return true; + } + + void restore(int segStart, int segLen, int nextIdx) { + this.segStart = segStart; + this.segLen = segLen; + this.nextIdx = nextIdx; + this.idx = nextIdx; + } + + int segStart() { + return segStart; + } + + int segLen() { + return segLen; + } + + int nextIdx() { + return nextIdx; + } + + int length() { + return len; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/LiteralLookup.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/LiteralLookup.java new file mode 100644 index 0000000..bc5ed7d --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/LiteralLookup.java @@ -0,0 +1,160 @@ +package dev.relism.fpr.core.internal.runtime.lookup; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.internal.runtime.ByteCompare; +import dev.relism.fpr.core.internal.runtime.FrozenRouter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Lookup strategies for literal edges, selected per state at compile-time. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LiteralLookup { + public static int find(FrozenRouter router, + ByteView input, + int segStart, + int segLen, + boolean supportsLong, + int state) { + int count = router.stateLiteralCount[state]; + if (count == 0) { + return -1; + } + int start = router.stateLiteralStart[state]; + byte strategy = router.stateLiteralStrategy[state]; + if (strategy == LiteralLookupStrategy.LINEAR) { + return linear(router, input, segStart, segLen, supportsLong, start, count); + } + if (strategy == LiteralLookupStrategy.ORDERED_PREFIX) { + return orderedPrefix(router, input, segStart, segLen, supportsLong, start, count); + } + if (strategy == LiteralLookupStrategy.HASH) { + return hash(router, input, segStart, segLen, supportsLong, state, start, count); + } + return linear(router, input, segStart, segLen, supportsLong, start, count); + } + + private static int linear(FrozenRouter router, + ByteView input, + int segStart, + int segLen, + boolean supportsLong, + int start, + int count) { + int end = start + count; + for (int i = start; i < end; i++) { + if (router.edgeLabelLen[i] != segLen) { + continue; + } + if (ByteCompare.equals(input, segStart, router.blob, router.edgeLabelOff[i], segLen, supportsLong)) { + return i; + } + } + return -1; + } + + private static int orderedPrefix(FrozenRouter router, + ByteView input, + int segStart, + int segLen, + boolean supportsLong, + int start, + int count) { + long key = prefixKey(input, segStart, segLen, supportsLong); + int lo = start; + int hi = start + count - 1; + while (lo <= hi) { + int mid = (lo + hi) >>> 1; + long midKey = router.edgeLiteralPrefix[mid]; + int cmp = compare(segLen, key, router.edgeLabelLen[mid], midKey); + if (cmp < 0) { + hi = mid - 1; + } else if (cmp > 0) { + lo = mid + 1; + } else { + int left = mid; + while (left > start && compare(segLen, key, router.edgeLabelLen[left - 1], router.edgeLiteralPrefix[left - 1]) == 0) { + left--; + } + int right = mid; + int end = start + count; + while (right + 1 < end && compare(segLen, key, router.edgeLabelLen[right + 1], router.edgeLiteralPrefix[right + 1]) == 0) { + right++; + } + for (int i = left; i <= right; i++) { + if (router.edgeLabelLen[i] != segLen) { + continue; + } + if (ByteCompare.equals(input, segStart, router.blob, router.edgeLabelOff[i], segLen, supportsLong)) { + return i; + } + } + return -1; + } + } + return -1; + } + + private static int hash(FrozenRouter router, + ByteView input, + int segStart, + int segLen, + boolean supportsLong, + int state, + int start, + int count) { + int mask = router.stateLiteralHashMask[state]; + int off = router.stateLiteralHashOff[state]; + if (mask <= 0 || off < 0) { + return linear(router, input, segStart, segLen, supportsLong, start, count); + } + long key = prefixKey(input, segStart, segLen, supportsLong); + int slot = mix(key) & mask; + while (true) { + long stored = router.literalHashKey[off + slot]; + if (stored == 0L) { + return -1; + } + if (stored == key) { + int edgeIndex = router.literalHashEdge[off + slot]; + if (edgeIndex >= start && edgeIndex < start + count && router.edgeLabelLen[edgeIndex] == segLen + && ByteCompare.equals(input, segStart, router.blob, router.edgeLabelOff[edgeIndex], segLen, supportsLong)) { + return edgeIndex; + } + } + slot = (slot + 1) & mask; + } + } + + private static int compare(int aLen, long aKey, short bLen, long bKey) { + if (aLen != bLen) { + return Integer.compare(aLen, bLen); + } + return Long.compareUnsigned(aKey, bKey); + } + + private static long prefixKey(ByteView input, + int segStart, + int segLen, + boolean supportsLong) { + if (segLen >= 8 && supportsLong && segStart + 8 <= input.length()) { + return input.longAt(segStart); + } + long key = 0; + int len = Math.min(8, segLen); + for (int i = 0; i < len; i++) { + key |= ((long) input.byteAt(segStart + i) & 0xFFL) << (i * 8); + } + return key; + } + + private static int mix(long key) { + long h = key ^ (key >>> 33); + h *= 0xff51afd7ed558ccdL; + h ^= (h >>> 33); + h *= 0xc4ceb9fe1a85ec53L; + h ^= (h >>> 33); + return (int) h; + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/LiteralLookupStrategy.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/LiteralLookupStrategy.java new file mode 100644 index 0000000..706861e --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/LiteralLookupStrategy.java @@ -0,0 +1,14 @@ +package dev.relism.fpr.core.internal.runtime.lookup; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Strategy ids for literal edge lookup, selected at compile time. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class LiteralLookupStrategy { + public static final byte LINEAR = 0; + public static final byte ORDERED_PREFIX = 1; + public static final byte HASH = 2; +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/MixedLookup.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/MixedLookup.java new file mode 100644 index 0000000..1c15887 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/MixedLookup.java @@ -0,0 +1,102 @@ +package dev.relism.fpr.core.internal.runtime.lookup; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.MatchResult; +import dev.relism.fpr.core.internal.runtime.ByteCompare; +import dev.relism.fpr.core.internal.runtime.FrozenRouter; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Matching strategies for mixed literal/param segments. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MixedLookup { + public static boolean match(FrozenRouter router, + int edgeIndex, + ByteView input, + int segStart, + int segLen, + boolean supportsLong, + MatchResult out) { + byte strategy = router.edgeMixedStrategy[edgeIndex]; + if (strategy == MixedLookupStrategy.ONE) { + return matchOne(router, edgeIndex, input, segStart, segLen, supportsLong, out); + } + return matchGeneral(router, edgeIndex, input, segStart, segLen, supportsLong, out); + } + + private static boolean matchOne(FrozenRouter router, + int edgeIndex, + ByteView input, + int segStart, + int segLen, + boolean supportsLong, + MatchResult out) { + int chunkOff = router.edgeMixedChunkOff[edgeIndex]; + int paramOff = router.edgeMixedParamOff[edgeIndex]; + int lit0Off = router.mixedChunkOff[chunkOff]; + int lit0Len = router.mixedChunkLen[chunkOff]; + int lit1Off = router.mixedChunkOff[chunkOff + 1]; + int lit1Len = router.mixedChunkLen[chunkOff + 1]; + int required = lit0Len + lit1Len; + int paramLen = segLen - required; + if (paramLen <= 0) { + return false; + } + if (!ByteCompare.equals(input, segStart, router.blob, lit0Off, lit0Len, supportsLong)) { + return false; + } + int suffixStart = segStart + segLen - lit1Len; + if (!ByteCompare.equals(input, suffixStart, router.blob, lit1Off, lit1Len, supportsLong)) { + return false; + } + int paramStart = segStart + lit0Len; + out.addParam(router.mixedParamKeyId[paramOff], paramStart, paramLen); + return true; + } + + private static boolean matchGeneral(FrozenRouter router, + int edgeIndex, + ByteView input, + int segStart, + int segLen, + boolean supportsLong, + MatchResult out) { + int chunkOff = router.edgeMixedChunkOff[edgeIndex]; + int chunkCount = router.edgeMixedChunkCount[edgeIndex]; + int paramOff = router.edgeMixedParamOff[edgeIndex]; + int paramCount = router.edgeMixedParamCount[edgeIndex]; + int end = segStart + segLen; + int cursor = segStart; + + for (int i = 0; i < paramCount; i++) { + int litOff = router.mixedChunkOff[chunkOff + i]; + int litLen = router.mixedChunkLen[chunkOff + i]; + if (!ByteCompare.equals(input, cursor, router.blob, litOff, litLen, supportsLong)) { + return false; + } + cursor += litLen; + int nextLitOff = router.mixedChunkOff[chunkOff + i + 1]; + int nextLitLen = router.mixedChunkLen[chunkOff + i + 1]; + int nextPos; + if (nextLitLen == 0) { + nextPos = end; + } else { + nextPos = ByteCompare.indexOf(input, cursor, end - nextLitLen, router.blob, nextLitOff, nextLitLen, supportsLong); + if (nextPos < 0) { + return false; + } + } + int paramLen = nextPos - cursor; + if (paramLen <= 0) { + return false; + } + out.addParam(router.mixedParamKeyId[paramOff + i], cursor, paramLen); + cursor = nextPos; + } + int tailOff = router.mixedChunkOff[chunkOff + chunkCount - 1]; + int tailLen = router.mixedChunkLen[chunkOff + chunkCount - 1]; + return ByteCompare.equals(input, cursor, router.blob, tailOff, tailLen, supportsLong); + } +} diff --git a/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/MixedLookupStrategy.java b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/MixedLookupStrategy.java new file mode 100644 index 0000000..f7d31b3 --- /dev/null +++ b/fpr-core/src/main/java/dev/relism/fpr/core/internal/runtime/lookup/MixedLookupStrategy.java @@ -0,0 +1,14 @@ +package dev.relism.fpr.core.internal.runtime.lookup; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Strategy ids for mixed-segment matching, selected at compile time. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MixedLookupStrategy { + public static final byte ONE = 0; + public static final byte TWO = 1; + public static final byte N = 2; +} diff --git a/fpr-core/src/test/java/dev/relism/fpr/core/RouterConcurrencyTest.java b/fpr-core/src/test/java/dev/relism/fpr/core/RouterConcurrencyTest.java new file mode 100644 index 0000000..2ffa6a4 --- /dev/null +++ b/fpr-core/src/test/java/dev/relism/fpr/core/RouterConcurrencyTest.java @@ -0,0 +1,145 @@ +package dev.relism.fpr.core; + +import dev.relism.fpr.core.dsl.StringRouteParser; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RouterConcurrencyTest { + + @Test + void testConcurrentMatchingDoesNotCorruptState() throws InterruptedException { + int threadCount = 20; + int iterationsPerThread = 100_000; + + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/users"), "USERS_LIST"); + builder.add(StringRouteParser.parse("/users/{id}"), "USER_DETAIL"); + builder.add(StringRouteParser.parse("/users/{id}/orders/{orderId}"), "USER_ORDER"); + builder.add(StringRouteParser.parse("/static/pre-{x}-suf"), "STATIC_MIXED"); + builder.add(StringRouteParser.parse("/assets/**"), "ASSETS_CATCH_ALL"); + + FastPathRouter router = builder.compile(); + + byte[] path1 = "/users".getBytes(StandardCharsets.US_ASCII); + byte[] path2 = "/users/123".getBytes(StandardCharsets.US_ASCII); + byte[] path3 = "/users/123/orders/abc".getBytes(StandardCharsets.US_ASCII); + byte[] path4 = "/static/pre-xyz-suf".getBytes(StandardCharsets.US_ASCII); + byte[] path5 = "/assets/css/style.css".getBytes(StandardCharsets.US_ASCII); + byte[] path6 = "/not-found".getBytes(StandardCharsets.US_ASCII); + + ByteView[] views = new ByteView[] { + new ByteArrayView(path1), + new ByteArrayView(path2), + new ByteArrayView(path3), + new ByteArrayView(path4), + new ByteArrayView(path5), + new ByteArrayView(path6) + }; + + String[] expectedHandlers = new String[] { + "USERS_LIST", + "USER_DETAIL", + "USER_ORDER", + "STATIC_MIXED", + "ASSETS_CATCH_ALL", + null + }; + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount); + + List> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + final int threadIndex = i; + futures.add(executor.submit((Callable) () -> { + MatchResult result = new MatchResult<>(builder.maxParamCount(), 64); + int localFailures = 0; + + startLatch.await(); // wait for all threads to be ready + + for (int j = 0; j < iterationsPerThread; j++) { + int pathIndex = (j + threadIndex) % views.length; + ByteView view = views[pathIndex]; + String expectedHandler = expectedHandlers[pathIndex]; + + result.reset(); + int routeId = router.match(view, result); + + if (expectedHandler == null) { + if (routeId != FastPathRouter.NO_MATCH || result.handler() != null) { + localFailures++; + } + } else { + if (routeId == FastPathRouter.NO_MATCH || !expectedHandler.equals(result.handler())) { + localFailures++; + } + } + } + endLatch.countDown(); + return localFailures; + })); + } + + // Fire! + startLatch.countDown(); + boolean completed = endLatch.await(30, TimeUnit.SECONDS); + + assertTrue(completed, "Concurrency test timed out"); + + int totalFailures = 0; + for (Future future : futures) { + try { + totalFailures += future.get(); + } catch (Exception e) { + totalFailures++; + } + } + + executor.shutdownNow(); + + // 0 failures means every matching attempt returned the correct route + assertEquals(0, totalFailures, "There were mismatched routes during concurrent access (race condition)"); + } + + private static final class ByteArrayView implements ByteView { + private final byte[] bytes; + + private ByteArrayView(byte[] bytes) { + this.bytes = bytes; + } + + @Override + public int length() { + return bytes.length; + } + + @Override + public byte byteAt(int index) { + return bytes[index]; + } + + @Override + public boolean supportsLong() { + return false; + } + + @Override + public long longAt(int index) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/fpr-core/src/test/java/dev/relism/fpr/core/RouterMatchTest.java b/fpr-core/src/test/java/dev/relism/fpr/core/RouterMatchTest.java new file mode 100644 index 0000000..811ebef --- /dev/null +++ b/fpr-core/src/test/java/dev/relism/fpr/core/RouterMatchTest.java @@ -0,0 +1,277 @@ +package dev.relism.fpr.core; + +import dev.relism.fpr.core.dsl.StringRouteParser; +import org.junit.jupiter.api.Test; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class RouterMatchTest { + @Test + void routePrecedencePrefersLiteralMixedParamWildcardCatchAll() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/a/b"), "LITERAL"); + builder.add(StringRouteParser.parse("/a/pre-{x}-suf"), "MIXED"); + builder.add(StringRouteParser.parse("/a/{id}"), "PARAM"); + builder.add(StringRouteParser.parse("/a/*"), "WILD"); + builder.add(StringRouteParser.parse("/a/**"), "CATCH"); + + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount()); + + assertThat(router.match(view("/a/b"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("LITERAL"); + + assertThat(router.match(view("/a/pre-123-suf"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("MIXED"); + + assertThat(router.match(view("/a/zzz"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("PARAM"); + + assertThat(router.match(view("/a/zzz/extra"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("CATCH"); + } + + @Test + void mixedSegmentsCaptureParams() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/some{id}"), "A"); + builder.add(StringRouteParser.parse("/pre-{x}-suf"), "B"); + builder.add(RoutePattern.of(RoutePattern.mixed(new String[]{"", "-suf"}, new String[]{"x"})), "C"); + builder.add(RoutePattern.of(RoutePattern.mixed(new String[]{"pre-", ""}, new String[]{"x"})), "D"); + + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount()); + + router.match(view("/some123"), out); + assertThat(out.paramCount()).isEqualTo(1); + assertThat(span(view("/some123"), out, 0)).isEqualTo("123"); + + router.match(view("/pre-abc-suf"), out); + assertThat(out.paramCount()).isEqualTo(1); + assertThat(span(view("/pre-abc-suf"), out, 0)).isEqualTo("abc"); + + router.match(view("/123-suf"), out); + assertThat(out.paramCount()).isEqualTo(1); + assertThat(out.handler()).isEqualTo("C"); + assertThat(span(view("/123-suf"), out, 0)).isEqualTo("123"); + + router.match(view("/pre-123"), out); + assertThat(out.paramCount()).isEqualTo(1); + assertThat(out.handler()).isEqualTo("D"); + assertThat(span(view("/pre-123"), out, 0)).isEqualTo("123"); + } + + @Test + void mixedEmptyPrefixStillMatches() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(RoutePattern.of(RoutePattern.mixed(new String[]{"", "-verylongsuffix"}, new String[]{"x"})), "A"); + builder.add(StringRouteParser.parse("/b{y}"), "B"); + + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount()); + + assertThat(router.match(view("/abc-verylongsuffix"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("A"); + assertThat(span(view("/abc-verylongsuffix"), out, 0)).isEqualTo("abc"); + } + + @Test + void fallbackToLowerPrecedenceWhenHigherPathFails() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/a/b/x"), "LIT"); + builder.add(StringRouteParser.parse("/a/{id}/y"), "PARAM"); + builder.add(StringRouteParser.parse("/a/**"), "CATCH"); + + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount(), 64); + + router.match(view("/a/b/y"), out); + assertThat(out.handler()).isEqualTo("PARAM"); + + router.match(view("/a/123/z"), out); + assertThat(out.handler()).isEqualTo("CATCH"); + } + + @Test + void indexedLiteralLookupMatchesAcrossStates() { + RouterBuilder builder = new RouterBuilder<>(); + for (int i = 0; i < 20; i++) { + builder.add(StringRouteParser.parse("/s0/r" + i), "S0-" + i); + builder.add(StringRouteParser.parse("/s1/r" + i), "S1-" + i); + builder.add(StringRouteParser.parse("/s2/r" + i), "S2-" + i); + } + + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount(), 64); + + assertThat(router.match(view("/s0/r19"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("S0-19"); + + assertThat(router.match(view("/s1/r7"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("S1-7"); + + assertThat(router.match(view("/s2/r3"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("S2-3"); + } + + @Test + void matchDoesNotReplaceResultArrays() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/a/{id}"), "A"); + + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount()); + int[] keyIds = out.keyIdsArray(); + int[] starts = out.startsArray(); + int[] lens = out.lensArray(); + + router.match(view("/a/123"), out); + + assertThat(out.keyIdsArray()).isSameAs(keyIds); + assertThat(out.startsArray()).isSameAs(starts); + assertThat(out.lensArray()).isSameAs(lens); + } + + @Test + void forEachParamProvidesNamesAndSpans() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/users/{id}/orders/{orderId}"), "A"); + + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount()); + ByteView view = view("/users/42/orders/7"); + + assertThat(router.match(view, out)).isNotEqualTo(FastPathRouter.NO_MATCH); + + String[] paramNames = builder.paramNames(); + List seen = new ArrayList<>(); + out.forEachParam(view, paramNames, (name, bytes, start, len) -> { + seen.add(name + "=" + span(bytes, start, len)); + }); + + assertThat(seen).containsExactly("id=42", "orderId=7"); + } + + @Test + void paramNamesAreOrderedAndUnique() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/a/{id}"), "A"); + builder.add(StringRouteParser.parse("/b/{id}/c/{slug}"), "B"); + builder.add(StringRouteParser.parse("/c/{slug}/d/{id}"), "C"); + + assertThat(builder.paramNames()).containsExactly("id", "slug"); + } + + @Test + void forEachParamRejectsMissingNameEntries() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/users/{id}/orders/{orderId}"), "A"); + + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount()); + ByteView view = view("/users/42/orders/7"); + router.match(view, out); + + String[] paramNames = new String[]{"id"}; + assertThatThrownBy(() -> out.forEachParam(view, paramNames, (name, bytes, start, len) -> { + })).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void labelsDifferentiateSamePath() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add("GET", StringRouteParser.parse("/users"), "GET_HANDLER"); + builder.add("POST", StringRouteParser.parse("/users"), "POST_HANDLER"); + + int getId = builder.labelId("GET"); + int postId = builder.labelId("POST"); + + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount()); + + out.labelId(getId); + assertThat(router.match(view("/users"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("GET_HANDLER"); + + out.labelId(postId); + assertThat(router.match(view("/users"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("POST_HANDLER"); + } + + @Test + void labelZeroMatchesAny() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add("GET", StringRouteParser.parse("/assets"), "GET_ASSETS"); + builder.add(StringRouteParser.parse("/assets"), "ANY_ASSETS"); + + int getId = builder.labelId("GET"); + FastPathRouter router = builder.compile(); + MatchResult out = new MatchResult<>(builder.maxParamCount()); + + out.labelId(getId); + assertThat(router.match(view("/assets"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("GET_ASSETS"); + + out.labelId(0); + assertThat(router.match(view("/assets"), out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("ANY_ASSETS"); + } + + private static ByteView view(String path) { + return new ByteArrayView(path.getBytes(StandardCharsets.US_ASCII)); + } + + private static String span(ByteView view, MatchResult out, int index) { + int start = out.startAt(index); + int len = out.lenAt(index); + byte[] bytes = new byte[len]; + for (int i = 0; i < len; i++) { + bytes[i] = view.byteAt(start + i); + } + return new String(bytes, StandardCharsets.US_ASCII); + } + + private static String span(ByteView view, int start, int len) { + byte[] bytes = new byte[len]; + for (int i = 0; i < len; i++) { + bytes[i] = view.byteAt(start + i); + } + return new String(bytes, StandardCharsets.US_ASCII); + } + + private static final class ByteArrayView implements ByteView { + private static final VarHandle LONG_VIEW = MethodHandles.byteArrayViewVarHandle(long[].class, java.nio.ByteOrder.LITTLE_ENDIAN); + private final byte[] bytes; + + private ByteArrayView(byte[] bytes) { + this.bytes = bytes; + } + + @Override + public int length() { + return bytes.length; + } + + @Override + public byte byteAt(int index) { + return bytes[index]; + } + + @Override + public boolean supportsLong() { + return true; + } + + @Override + public long longAt(int index) { + return (long) LONG_VIEW.get(bytes, index); + } + } +} diff --git a/fpr-netty/pom.xml b/fpr-netty/pom.xml new file mode 100644 index 0000000..4d276d3 --- /dev/null +++ b/fpr-netty/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + + dev.relism + fastpathrouter-parent + 1.0-SNAPSHOT + + + fpr-netty + jar + + + + + io.netty + netty-bom + ${netty.version} + pom + import + + + + + + + dev.relism + fpr-core + + + io.netty + netty-buffer + + + io.netty + netty-codec-http + + + org.slf4j + slf4j-api + + + org.projectlombok + lombok + provided + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + test + + + org.slf4j + slf4j-simple + test + + + diff --git a/fpr-netty/src/main/java/dev/relism/fpr/netty/NettyByteBufView.java b/fpr-netty/src/main/java/dev/relism/fpr/netty/NettyByteBufView.java new file mode 100644 index 0000000..136280b --- /dev/null +++ b/fpr-netty/src/main/java/dev/relism/fpr/netty/NettyByteBufView.java @@ -0,0 +1,56 @@ +package dev.relism.fpr.netty; + +import dev.relism.fpr.core.ByteView; +import io.netty.buffer.ByteBuf; + +public final class NettyByteBufView implements ByteView { + private final ByteBuf buffer; + private final int offset; + private final int length; + + public NettyByteBufView(ByteBuf buffer, int offset, int length) { + if (buffer == null) { + throw new IllegalArgumentException("buffer must not be null"); + } + if (offset < 0 || length < 0 || offset + length > buffer.capacity()) { + throw new IllegalArgumentException("invalid offset/length"); + } + this.buffer = buffer; + this.offset = offset; + this.length = length; + } + + public ByteBuf buffer() { + return buffer; + } + + public int offset() { + return offset; + } + + @Override + public int length() { + return length; + } + + @Override + public byte byteAt(int index) { + if (index < 0 || index >= length) { + throw new IndexOutOfBoundsException("index: " + index); + } + return buffer.getByte(offset + index); + } + + @Override + public boolean supportsLong() { + return true; + } + + @Override + public long longAt(int index) { + if (index < 0 || index + 8 > length) { + throw new IndexOutOfBoundsException("index: " + index); + } + return buffer.getLongLE(offset + index); + } +} diff --git a/fpr-netty/src/main/java/dev/relism/fpr/netty/NettyPathExtractor.java b/fpr-netty/src/main/java/dev/relism/fpr/netty/NettyPathExtractor.java new file mode 100644 index 0000000..c3175fa --- /dev/null +++ b/fpr-netty/src/main/java/dev/relism/fpr/netty/NettyPathExtractor.java @@ -0,0 +1,64 @@ +package dev.relism.fpr.netty; + +import io.netty.buffer.ByteBuf; + +public final class NettyPathExtractor { + private NettyPathExtractor() { + } + + public static PathSpan extractFromRequestLine(ByteBuf buffer, int offset, int length, PathSpan out) { + if (out == null) { + throw new IllegalArgumentException("out must not be null"); + } + int end = offset + length; + int i = offset; + while (i < end && buffer.getByte(i) != ' ') { + i++; + } + if (i >= end) { + throw new IllegalArgumentException("invalid request line"); + } + i++; + int pathStart = i; + while (i < end && buffer.getByte(i) != ' ') { + i++; + } + int pathLen = i - pathStart; + out.set(pathStart, pathLen); + return out; + } + + public static PathSpan extractFromUri(CharSequence uri, PathSpan out) { + if (out == null) { + throw new IllegalArgumentException("out must not be null"); + } + int len = uri.length(); + int end = len; + for (int i = 0; i < len; i++) { + if (uri.charAt(i) == '?') { + end = i; + break; + } + } + out.set(0, end); + return out; + } + + public static final class PathSpan { + private int start; + private int len; + + public int start() { + return start; + } + + public int len() { + return len; + } + + public void set(int start, int len) { + this.start = start; + this.len = len; + } + } +} diff --git a/fpr-netty/src/test/java/dev/relism/fpr/netty/NettyAdaptersTest.java b/fpr-netty/src/test/java/dev/relism/fpr/netty/NettyAdaptersTest.java new file mode 100644 index 0000000..5a37b8a --- /dev/null +++ b/fpr-netty/src/test/java/dev/relism/fpr/netty/NettyAdaptersTest.java @@ -0,0 +1,63 @@ +package dev.relism.fpr.netty; + +import dev.relism.fpr.core.ByteView; +import dev.relism.fpr.core.FastPathRouter; +import dev.relism.fpr.core.MatchResult; +import dev.relism.fpr.core.RouterBuilder; +import dev.relism.fpr.core.dsl.StringRouteParser; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +class NettyAdaptersTest { + @Test + void byteBufViewRespectsOffsetAndLength() { + ByteBuf buf = Unpooled.wrappedBuffer("abcdef".getBytes(StandardCharsets.US_ASCII)); + NettyByteBufView view = new NettyByteBufView(buf, 1, 3); + + assertThat(view.length()).isEqualTo(3); + assertThat((char) view.byteAt(0)).isEqualTo('b'); + assertThat((char) view.byteAt(2)).isEqualTo('d'); + } + + @Test + void extractPathFromRequestLine() { + ByteBuf buf = Unpooled.wrappedBuffer("GET /alpha/beta?q=1 HTTP/1.1".getBytes(StandardCharsets.US_ASCII)); + NettyPathExtractor.PathSpan out = new NettyPathExtractor.PathSpan(); + + NettyPathExtractor.extractFromRequestLine(buf, 0, buf.readableBytes(), out); + + String path = buf.toString(out.start(), out.len(), StandardCharsets.US_ASCII); + assertThat(path).isEqualTo("/alpha/beta?q=1"); + } + + @Test + void nettyIntegrationMatch() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("/user/{id}"), "USER"); + FastPathRouter router = builder.compile(); + + ByteBuf buf = Unpooled.wrappedBuffer("/user/42".getBytes(StandardCharsets.US_ASCII)); + NettyByteBufView view = new NettyByteBufView(buf, 0, buf.readableBytes()); + + MatchResult out = new MatchResult<>(builder.maxParamCount()); + assertThat(router.match(view, out)).isNotEqualTo(FastPathRouter.NO_MATCH); + assertThat(out.handler()).isEqualTo("USER"); + assertThat(out.paramCount()).isEqualTo(1); + assertThat(span(view, out, 0)).isEqualTo("42"); + } + + private static String span(ByteView view, MatchResult out, int index) { + int start = out.startAt(index); + int len = out.lenAt(index); + byte[] bytes = new byte[len]; + for (int i = 0; i < len; i++) { + bytes[i] = view.byteAt(start + i); + } + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..05bf864 --- /dev/null +++ b/pom.xml @@ -0,0 +1,121 @@ + + + 4.0.0 + + dev.relism + fastpathrouter-parent + 1.0-SNAPSHOT + pom + + + fpr-core + fpr-netty + fpr-bench + + + + 11 + UTF-8 + + 4.2.9.Final + 1.18.42 + 2.0.17 + 5.14.2 + 3.27.6 + 1.37 + + + + + Personal + https://maven.relism.dev/releases + + + + + + + dev.relism + fpr-core + ${project.version} + + + dev.relism + fpr-netty + ${project.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + + + org.assertj + assertj-core + ${assertj.version} + + + org.slf4j + slf4j-simple + ${slf4j.version} + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${maven.compiler.release} + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + false + + + + + +