weeks of bullshit

This commit is contained in:
Relism
2026-04-17 08:18:11 +02:00
parent b5d4481502
commit 9efbe38c0c
157 changed files with 5171 additions and 1273 deletions
@@ -9,6 +9,8 @@ import java.util.concurrent.atomic.AtomicLong;
* <ul>
* <li><b>FIXED_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
* {@code slot1} unused.</li>
* <li><b>SLIDING_WINDOW</b>: {@code slot0} = packed {@code (epoch << 32 | count)},
* {@code slot1} = request count from the immediately preceding epoch.</li>
* <li><b>TOKEN_BUCKET</b>: {@code slot0} = tokens × 1000 (scaled),
* {@code slot1} = last-refill timestamp (ms since epoch).</li>
* </ul>
@@ -16,17 +16,15 @@ import java.util.concurrent.TimeUnit;
* time — the resolver lambda is captured directly from the registry (no runtime map lookup):
* <pre>{@code
* // 50 req/s per IP — fixed window (default)
* app.get("/api/search", handler)
* .with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
*
* // 10 req/min per authenticated user — token bucket
* app.post("/api/export", handler)
* .with(guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
* app.post("/api/export", handler, guard.limit("auth_user", 10, 1, TimeUnit.MINUTES, LimitStrategy.TOKEN_BUCKET));
* }</pre>
*
* <p>The resolver name is looked up once here (at {@code .with(guard.limit(...))} call time,
* i.e. during app wiring, not on each request). If the name is not registered,
* {@link dev.relism.exceptions.InitializationException} is thrown immediately.
* <p>The resolver name is looked up once here (at wiring time, not on each request).
* If the name is not registered, {@link dev.relism.exceptions.InitializationException}
* is thrown immediately.
*/
public final class Guard {
@@ -1,6 +1,7 @@
package dev.relism.ext.limiter;
import dev.relism.ext.limiter.strategy.FixedWindowStrategy;
import dev.relism.ext.limiter.strategy.SlidingWindowStrategy;
import dev.relism.ext.limiter.strategy.TokenBucketStrategy;
/**
@@ -38,6 +39,18 @@ public enum LimitStrategy {
TOKEN_BUCKET {
@Override
public RateLimitStrategy create() { return new TokenBucketStrategy(); }
},
/**
* Sliding-window counter: interpolates between the previous window's count and the
* current window's count weighted by how far into the current window we are.
* Eliminates the boundary burst of {@link #FIXED_WINDOW} while remaining O(1)
* memory and lock-free. Slight approximation — worst-case error ≈ a few percent at
* window boundaries.
*/
SLIDING_WINDOW {
@Override
public RateLimitStrategy create() { return new SlidingWindowStrategy(); }
};
/** Creates a fresh, stateless {@link RateLimitStrategy} instance for this algorithm. */
@@ -1,24 +1,24 @@
package dev.relism.ext.limiter;
import dev.relism.extension.ExtensionPhase;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
import dev.relism.http.HttpStatus;
import dev.relism.routing.Middleware;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* Rate-limiting extension for Flash.
*
* <p>On {@link #install}:
* <p>At {@link #provide}:
* <ol>
* <li>Creates a single {@link BucketStore} shared by all rules in this extension instance.</li>
* <li>Provides a {@link Guard} in the {@link FlashContext} for manual use on lambda routes.</li>
* <li>Registers an {@link dev.relism.extension.AnnotationProcessor} for {@link Limit}:
* reads the annotation once per handler class at boot, resolves the key lambda
* from the registry <em>fail-fast</em>, then returns a pre-compiled middleware
* that captures the lambda and config directly — zero map lookups at request time.</li>
* fail-fast, then returns a pre-compiled middleware — zero map lookups at request time.</li>
* </ol>
*
* <h3>Annotation-based (class handlers)</h3>
@@ -34,23 +34,24 @@ import java.util.List;
*
* <h3>Lambda routes (via Guard)</h3>
* <pre>{@code
* LimiterConfig conf = new LimiterConfig()
* .registerResolver("auth_user", req -> ClaimsHolder.user().sub());
* app.install(new LimiterExtension(
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
*
* app.install(new LimiterExtension(conf));
*
* Guard guard = app.ctx().require(Guard.class);
* app.get("/api/search", handler).with(guard.limit("ip", 50, 1, TimeUnit.SECONDS));
* // inside FlashExtension.routes() or after install():
* Guard guard = ctx.require(Guard.class);
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
* }</pre>
*
* <h3>Installation order matters</h3>
* Install {@code LimiterExtension} <em>before</em> authentication extensions so that
* rate-limit checks short-circuit before expensive token validation on over-limit requests.
*/
public final class LimiterExtension implements FlashExtension {
private final LimiterConfig config;
/**
* Rate limiting runs before authentication — cheaper check rejects over-limit
* requests before any token validation occurs.
*/
@Override public int priority() { return ExtensionPhase.EARLY.value; }
/** Installs with default config (only the built-in {@code "ip"} resolver). */
public LimiterExtension() {
this(new LimiterConfig());
@@ -62,7 +63,7 @@ public final class LimiterExtension implements FlashExtension {
}
@Override
public void install(FlashRegistrar app, FlashContext ctx) {
public void provide(FlashContext ctx) {
BucketStore store = new BucketStore();
Guard guard = new Guard(config, store);
@@ -96,14 +97,13 @@ public final class LimiterExtension implements FlashExtension {
* <li>{@code resolver} is captured directly in the closure — no registry lookup per request.</li>
* <li>{@code resultBuf} is a per-{@link Middleware}-instance ThreadLocal {@code long[2]}.
* Allocated once per thread, reused forever — zero per-request allocation.</li>
* <li>Header values ({@code String.valueOf(...)}) are the only unavoidable allocations;
* they are tiny and bounded.</li>
* <li>The static {@code X-RateLimit-Limit} header is pre-encoded at boot — zero-alloc.</li>
* </ul>
*/
static Middleware buildMiddleware(KeyResolver resolver, LimitConfig cfg, BucketStore store) {
// One result buffer per thread, per middleware instance.
// ThreadLocal is captured in the closure at boot time — not re-created per request.
ThreadLocal<long[]> resultBuf = ThreadLocal.withInitial(() -> new long[2]);
ThreadLocal<long[]> resultBuf = ThreadLocal.withInitial(() -> new long[2]);
byte[] limitHeader = ("X-RateLimit-Limit: " + cfg.limit() + "\r\n")
.getBytes(StandardCharsets.UTF_8);
return next -> (req, res) -> {
String key = resolver.resolve(req);
@@ -112,8 +112,7 @@ public final class LimiterExtension implements FlashExtension {
boolean allowed = cfg.strategy().check(bucket, cfg, out);
// Always inject rate-limit headers — useful even on allowed requests.
res.header("X-RateLimit-Limit", String.valueOf(cfg.limit()));
res.header(limitHeader);
res.header("X-RateLimit-Remaining", String.valueOf(out[0]));
res.header("X-RateLimit-Reset", String.valueOf(out[1]));
@@ -11,6 +11,7 @@ package dev.relism.ext.limiter;
* <p>Called on every request — must not allocate on the hot path.
*
* @see dev.relism.ext.limiter.strategy.FixedWindowStrategy
* @see dev.relism.ext.limiter.strategy.SlidingWindowStrategy
* @see dev.relism.ext.limiter.strategy.TokenBucketStrategy
*/
public interface RateLimitStrategy {
@@ -0,0 +1,86 @@
package dev.relism.ext.limiter.strategy;
import dev.relism.ext.limiter.Bucket;
import dev.relism.ext.limiter.LimitConfig;
import dev.relism.ext.limiter.RateLimitStrategy;
/**
* Sliding-window counter rate limit: approximates a true sliding window by interpolating
* between the previous fixed window's count and the current window's count.
*
* <pre>
* estimate = prevCount × (1 elapsed / windowMs) + currentCount
* </pre>
*
* <p>This is the same approximation used by Redis. It eliminates the boundary burst
* problem of {@link FixedWindowStrategy} while staying O(1) memory and lock-free.
* The error is bounded: in the worst case the true rate at the boundary can exceed
* the limit by at most {@code limit × (1 elapsed/windowMs)} — typically a few percent.
*
* <h3>Slot layout</h3>
* <ul>
* <li>{@link Bucket#slot0} — packed {@code (reducedEpoch << 32 | currentCount)}</li>
* <li>{@link Bucket#slot1} — count from the immediately preceding epoch (0 = none)</li>
* </ul>
*
* <p>On a window transition the thread that wins the {@code slot0} CAS also writes
* {@code slot1}. A concurrent thread that reads {@code slot0} after the transition but
* before {@code slot1} is written sees a slightly stale previous count — acceptable for
* an approximation algorithm.
*/
public final class SlidingWindowStrategy implements RateLimitStrategy {
@Override
public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
long now = System.currentTimeMillis();
long windowMs = cfg.windowMs();
long absEpoch = now / windowMs;
int epoch = (int)(absEpoch & 0xFFFFFFFFL);
long elapsed = now % windowMs; // ms elapsed inside the current window
while (true) {
long packed = bucket.slot0.get();
int storedEpoch = (int)(packed >>> 32);
int count = (int)(packed & 0xFFFFFFFFL);
if (storedEpoch == epoch) {
// ── Same window ──────────────────────────────────────────────────
long prevCount = bucket.slot1.get();
// Integer interpolation — no floating-point on hot path.
long estimate = (prevCount * (windowMs - elapsed)) / windowMs + count + 1;
if (estimate > cfg.limit()) {
out[0] = 0L;
out[1] = (absEpoch + 1) * windowMs / 1000L;
return false;
}
int newCount = Math.min(count + 1, cfg.limit() + 1);
long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
out[0] = Math.max(0L, cfg.limit() - estimate);
out[1] = (absEpoch + 1) * windowMs / 1000L;
return true;
} else {
// ── Window transition ────────────────────────────────────────────
// If the stored epoch is exactly the one before ours, carry its count forward.
// If it's older (gap ≥ 2 windows), the previous window is effectively empty.
int prevEpoch = (int)((absEpoch - 1) & 0xFFFFFFFFL);
long oldCount = (storedEpoch == prevEpoch) ? count : 0L;
long newPacked = ((long) epoch << 32) | 1L;
if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
// Won the transition: publish old count so the same-window branch can read it.
bucket.slot1.set(oldCount);
long estimate = (oldCount * (windowMs - elapsed)) / windowMs + 1;
out[0] = Math.max(0L, cfg.limit() - estimate);
out[1] = (absEpoch + 1) * windowMs / 1000L;
return estimate <= cfg.limit();
}
}
}
}
@@ -17,9 +17,8 @@ import dev.relism.ext.limiter.RateLimitStrategy;
* <li>{@link Bucket#slot1} — last-refill timestamp in ms. 0 = not yet initialised.</li>
* </ul>
*
* <p>Each request CAS-loops on {@code slot0}; {@code slot1} is updated best-effort after a
* successful CAS. The resulting inaccuracy is bounded by the nanoseconds between the CAS
* and the {@code set} — negligible and self-correcting for rate limiting purposes.
* <p>Each request CAS-loops on {@code slot0}; {@code slot1} is advanced monotonically via CAS
* after a successful token consumption — never regresses to an older timestamp under concurrent load.
*/
public final class TokenBucketStrategy implements RateLimitStrategy {
@@ -57,8 +56,8 @@ public final class TokenBucketStrategy implements RateLimitStrategy {
long newTokens = currentTokens - SCALE;
if (bucket.slot0.compareAndSet(rawTokens, newTokens)) {
// Consumed successfully. Update refill baseline best-effort.
bucket.slot1.set(now);
// Advance refill baseline: CAS ensures we never regress to an older timestamp.
if (lastMs < now) bucket.slot1.compareAndSet(lastMs, now);
out[0] = newTokens / SCALE;
out[1] = now / 1000L;
return true;