initial ? wtf

This commit is contained in:
Relism
2026-03-15 01:15:43 +01:00
commit 58d8c94ea7
52 changed files with 5260 additions and 0 deletions
+18
View File
@@ -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/
+8
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/fpr-bench/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/fpr-bench/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/fpr-core/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/fpr-core/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/fpr-netty/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/fpr-netty/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="corretto-11" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+21
View File
@@ -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.
+49
View File
@@ -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<String> builder = new RouterBuilder<>();
builder.add(StringRouteParser.parse("/users/{id}/orders/{orderId}"), "H");
FastPathRouter<ByteView, String> router = builder.compile();
String[] paramNames = builder.paramNames();
ByteView view = ...;
MatchResult<String> 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
```
+23
View File
@@ -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.
+137
View File
@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>fastpathrouter-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>fpr-bench</artifactId>
<packaging>jar</packaging>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-bom</artifactId>
<version>${netty.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>fpr-core</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>fpr-netty</artifactId>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>bench</id>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<id>run-bench</id>
<phase>verify</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<executable>java</executable>
<arguments>
<argument>-jar</argument>
<argument>${project.build.directory}/${project.build.finalName}-shaded.jar</argument>
<argument>-wi</argument>
<argument>1</argument>
<argument>-i</argument>
<argument>1</argument>
<argument>-f</argument>
<argument>1</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
@@ -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);
}
}
@@ -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);
}
}
@@ -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<ByteView, String> routerSmall;
private FastPathRouter<ByteView, String> routerLiteralHeavy;
private FastPathRouter<ByteView, String> routerParamHeavy;
private FastPathRouter<ByteView, String> routerMixedHeavy;
private FastPathRouter<ByteView, String> routerCatchAll;
private FastPathRouter<ByteView, String> routerLarge;
private MatchResult<String> outSmall;
private MatchResult<String> 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<String> 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<ByteView, String> buildLiteralHeavy() {
RouterBuilder<String> builder = new RouterBuilder<>();
for (int i = 0; i < 200; i++) {
builder.add(StringRouteParser.parse("/route-" + i), "L" + i);
}
return builder.compile();
}
private FastPathRouter<ByteView, String> buildParamHeavy() {
RouterBuilder<String> builder = new RouterBuilder<>();
for (int i = 0; i < 200; i++) {
builder.add(StringRouteParser.parse("/p" + i + "/{id}"), "P" + i);
}
return builder.compile();
}
private FastPathRouter<ByteView, String> buildMixedHeavy() {
RouterBuilder<String> 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<ByteView, String> buildCatchAll() {
RouterBuilder<String> builder = new RouterBuilder<>();
builder.add(StringRouteParser.parse("/assets/**"), "CATCH");
builder.add(StringRouteParser.parse("/assets/images/**"), "CATCH2");
return builder.compile();
}
private FastPathRouter<ByteView, String> buildLarge() {
RouterBuilder<String> 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);
}
}
}
@@ -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);
}
}
+55
View File
@@ -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__<tag>__<type>.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`.
+536
View File
@@ -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:]))
+48
View File
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>fastpathrouter-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>fpr-core</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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");
}
}
@@ -0,0 +1,10 @@
package dev.relism.fpr.core;
/**
* Immutable router interface for hot-path matching.
*/
public interface FastPathRouter<I, H> {
int NO_MATCH = -1;
int match(I input, MatchResult<H> out);
}
@@ -0,0 +1,211 @@
package dev.relism.fpr.core;
import lombok.Setter;
/**
* Reusable match result container for parameter spans.
*/
public final class MatchResult<H> {
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<H> reset() {
this.paramCount = 0;
this.stackSize = 0;
this.handler = null;
return this;
}
public MatchResult<H> 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;
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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<Segment> segments;
RoutePattern(List<Segment> segments) {
this.segments = segments;
}
public List<Segment> segments() {
return segments;
}
public static RoutePattern of(Segment... segments) {
return new RoutePattern(Arrays.asList(segments));
}
public static RoutePattern fromSegments(List<Segment> 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;
}
}
}
@@ -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<H> {
private final List<RouteSpec<H>> routes = new ArrayList<>();
private final LinkedHashMap<String, Integer> paramIds = new LinkedHashMap<>();
private final List<String> paramNames = new ArrayList<>();
private final LinkedHashMap<String, Integer> labelIds = new LinkedHashMap<>();
public RouterBuilder<H> add(RoutePattern pattern, H handler) {
return add(0, pattern, handler);
}
public RouterBuilder<H> add(String label, RoutePattern pattern, H handler) {
return add(labelId(label), pattern, handler);
}
public RouterBuilder<H> add(Enum<?> label, RoutePattern pattern, H handler) {
return add(labelId(label), pattern, handler);
}
public RouterBuilder<H> 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<H> 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<H> 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<ByteView, H> 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<H> {
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;
}
}
}
@@ -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<RoutePattern.Segment> 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<String> literals = new ArrayList<>();
List<String> 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;
}
}
@@ -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 <H> FastPathRouter<ByteView, H> freeze(RouteGraph<H> graph) {
List<RouteGraph.Node<H>> nodes = graph.nodes;
List<H> handlers = graph.handlers;
int stateCount = nodes.size();
int totalEdges = 0;
int totalAccepts = 0;
for (RouteGraph.Node<H> 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<H> node = nodes.get(s);
stateFirstEdge[s] = edgeNextState.size();
List<RouteGraph.Edge> literals = node.literalEdges();
List<RouteGraph.Edge> 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()
);
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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 <H> FastPathRouter<ByteView, H> compile(List<RouterBuilder.RouteSpec<H>> routes,
Map<String, Integer> paramIds) {
if (routes.isEmpty()) {
return emptyRouter();
}
RouteGraph<H> graph = RouteGraph.build(routes, paramIds).canonicalize();
return FreezeWriter.freeze(graph);
}
private static <H> FastPathRouter<ByteView, H> 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
);
}
}
@@ -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<H> {
final List<Node<H>> nodes;
final List<H> handlers;
private RouteGraph(List<Node<H>> nodes, List<H> handlers) {
this.nodes = nodes;
this.handlers = handlers;
}
static <H> RouteGraph<H> build(List<RouterBuilder.RouteSpec<H>> routes, Map<String, Integer> paramIds) {
List<RouterBuilder.RouteSpec<H>> ordered = new ArrayList<>(routes);
ordered.sort(routeComparator());
List<Node<H>> nodes = new ArrayList<>();
Node<H> root = new Node<>(0);
nodes.add(root);
List<H> handlers = new ArrayList<>();
int routeId = 0;
for (RouterBuilder.RouteSpec<H> spec : ordered) {
int handlerId = handlers.size();
handlers.add(spec.handler());
Node<H> current = root;
List<RoutePattern.Segment> 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<H> canonicalize() {
int size = nodes.size();
int[] remap = new int[size];
Arrays.fill(remap, -1);
Map<NodeKey, Integer> canonical = new HashMap<>();
List<Node<H>> canonNodes = new ArrayList<>();
for (int i = size - 1; i >= 0; i--) {
Node<H> 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<H> 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<Node<H>> 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<H> node : reordered) {
node.remap(reorder);
}
return new RouteGraph<>(reordered, handlers);
}
return new RouteGraph<>(canonNodes, handlers);
}
private static int paramId(String name, Map<String, Integer> 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<String, Integer> 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<String, Integer> 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<RouterBuilder.RouteSpec<?>> routeComparator() {
return (a, b) -> {
List<RoutePattern.Segment> segA = a.pattern().segments();
List<RoutePattern.Segment> 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<Edge> 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<Edge> 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<H> {
final int id;
final List<Edge> edges = new ArrayList<>();
int paramNext = -1;
short paramKeyId = -1;
int catchAllNext = -1;
short catchAllKeyId = -1;
final List<Accept> accepts = new ArrayList<>();
Node(int id) {
this.id = id;
}
Node<H> literal(List<Node<H>> nodes, byte[] literal) {
for (Edge edge : edges) {
if (edge.kind == EdgeKind.LITERAL && Arrays.equals(edge.literal, literal)) {
return nodes.get(edge.nextState);
}
}
Node<H> next = new Node<>(nodes.size());
nodes.add(next);
edges.add(new Edge(EdgeKind.LITERAL, literal, null, next.id));
return next;
}
Node<H> mixed(List<Node<H>> 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<H> next = new Node<>(nodes.size());
nodes.add(next);
edges.add(new Edge(EdgeKind.MIXED, null, def, next.id));
return next;
}
Node<H> param(List<Node<H>> nodes, int keyId) {
if (paramNext != -1) {
if (paramKeyId != (short) keyId) {
throw new IllegalArgumentException("Ambiguous param segment at state " + id);
}
return nodes.get(paramNext);
}
Node<H> next = new Node<>(nodes.size());
nodes.add(next);
paramNext = next.id;
paramKeyId = (short) keyId;
return next;
}
Node<H> wild(List<Node<H>> nodes) {
for (Edge edge : edges) {
if (edge.kind == EdgeKind.WILD) {
return nodes.get(edge.nextState);
}
}
Node<H> next = new Node<>(nodes.size());
nodes.add(next);
edges.add(new Edge(EdgeKind.WILD, null, null, next.id));
return next;
}
Node<H> catchAll(List<Node<H>> 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<H> 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<Edge> literalEdges() {
List<Edge> out = new ArrayList<>();
for (Edge edge : edges) {
if (edge.kind == EdgeKind.LITERAL) {
out.add(edge);
}
}
return out;
}
List<Edge> mixedEdges() {
List<Edge> 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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,9 @@
package dev.relism.fpr.core.internal.runtime;
public enum EdgeKind {
LITERAL,
MIXED,
PARAM,
WILD,
CATCH
}
@@ -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<H> implements FastPathRouter<ByteView, H> {
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<H> 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<H> 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<H> 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;
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
@@ -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);
}
}
@@ -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;
}
@@ -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<String> 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<ByteView, String> 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<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < threadCount; i++) {
final int threadIndex = i;
futures.add(executor.submit((Callable<Integer>) () -> {
MatchResult<String> 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<Integer> 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();
}
}
}
@@ -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<String> 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<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> 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<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> builder = new RouterBuilder<>();
builder.add(RoutePattern.of(RoutePattern.mixed(new String[]{"", "-verylongsuffix"}, new String[]{"x"})), "A");
builder.add(StringRouteParser.parse("/b{y}"), "B");
FastPathRouter<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> 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<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> 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<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> builder = new RouterBuilder<>();
builder.add(StringRouteParser.parse("/a/{id}"), "A");
FastPathRouter<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> builder = new RouterBuilder<>();
builder.add(StringRouteParser.parse("/users/{id}/orders/{orderId}"), "A");
FastPathRouter<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> 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<String> 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<String> builder = new RouterBuilder<>();
builder.add(StringRouteParser.parse("/users/{id}/orders/{orderId}"), "A");
FastPathRouter<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> 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<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> builder = new RouterBuilder<>();
builder.add("GET", StringRouteParser.parse("/assets"), "GET_ASSETS");
builder.add(StringRouteParser.parse("/assets"), "ANY_ASSETS");
int getId = builder.labelId("GET");
FastPathRouter<ByteView, String> router = builder.compile();
MatchResult<String> 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<String> 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);
}
}
}
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>fastpathrouter-parent</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>fpr-netty</artifactId>
<packaging>jar</packaging>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-bom</artifactId>
<version>${netty.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>fpr-core</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -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);
}
}
@@ -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;
}
}
}
@@ -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<String> builder = new RouterBuilder<>();
builder.add(StringRouteParser.parse("/user/{id}"), "USER");
FastPathRouter<ByteView, String> router = builder.compile();
ByteBuf buf = Unpooled.wrappedBuffer("/user/42".getBytes(StandardCharsets.US_ASCII));
NettyByteBufView view = new NettyByteBufView(buf, 0, buf.readableBytes());
MatchResult<String> 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<String> 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);
}
}
+121
View File
@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.relism</groupId>
<artifactId>fastpathrouter-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>fpr-core</module>
<module>fpr-netty</module>
<module>fpr-bench</module>
</modules>
<properties>
<maven.compiler.release>11</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<netty.version>4.2.9.Final</netty.version>
<lombok.version>1.18.42</lombok.version>
<slf4j.version>2.0.17</slf4j.version>
<junit.version>5.14.2</junit.version>
<assertj.version>3.27.6</assertj.version>
<jmh.version>1.37</jmh.version>
</properties>
<distributionManagement>
<repository>
<id>Personal</id>
<url>https://maven.relism.dev/releases</url>
</repository>
</distributionManagement>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>fpr-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>fpr-netty</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>${junit.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.version}</version>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>${maven.compiler.release}</release>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
<configuration>
<useModulePath>false</useModulePath>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>