Compare commits

...
20 Commits
Author SHA1 Message Date
lukaszraczylo e2af5b079f fix(coalesce): copy the primary response body for waiters
Waiters read the primary response buffer while fasthttp could reuse it for another request. A waiter could then send the wrong body. The primary now gives waiters their own copy.
2026-11-03 00:02:15 +00:00
lukaszraczylo 10d3946f1e fix(rps): stop dropping requests recorded during a sample
The sampler read the counter and then reset it in two steps. Requests recorded between the steps were lost. The sampler now swaps the counter to zero in one atomic step.
2026-11-03 00:00:24 +00:00
lukaszraczylo ab63f924a9 fix(retry): carry fractional tokens across refill ticks
The refill loop dropped the fractional part of each tick. At 15 tokens per second and 100 ms ticks, it added 10 tokens per second instead of 15. The clock now advances only by the time the added tokens pay for.
2026-11-02 23:57:46 +00:00
github-actions[bot]andGitHub a0a8283b52 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-11-02 03:04:09 +00:00
github-actions[bot]andGitHub cf4f2785e2 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-11-01 03:04:09 +00:00
github-actions[bot]andGitHub 6be1194ff7 chore(deps): update module github.com/gookit/goutil to v0.8.6
Automated dependency update. All tests passed.
2026-10-31 03:04:59 +00:00
github-actions[bot]andGitHub 1dcff9d2e2 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-30 03:05:15 +00:00
github-actions[bot]andGitHub 523fc6222d chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-29 03:04:49 +00:00
lukaszraczylo 9658f09521 fix(cache): count keys with SCAN not KEYS
CountQueries and CountQueriesWithPattern ran KEYS. With an empty
prefix, KEYS * builds a reply of every key on the server. The stats
endpoint calls it on each request. On a shared Dragonfly with about
1M keys, this raised memory on the server.

Count with a cursor SCAN instead. The count does not hold the key
list in memory. A test checks that no KEYS command is sent.
2026-10-28 22:19:10 +00:00
github-actions[bot]andGitHub 8456860dbe chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-27 03:06:19 +00:00
github-actions[bot]andGitHub 5fa78e0fe8 chore(deps): update module github.com/gofiber/contrib/v3/websocket to v1.2.9
Automated dependency update. All tests passed.
2026-10-25 03:04:34 +00:00
github-actions[bot]andGitHub 54b83df04f chore(deps): update module github.com/gofiber/schema to v1.9.0
Automated dependency update. All tests passed.
2026-10-23 03:05:32 +00:00
github-actions[bot]andGitHub 899f71f4aa chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-21 03:05:20 +00:00
github-actions[bot]andGitHub 9ee213a018 chore(deps): update module github.com/philhofer/fwd to v1.2.1
Automated dependency update. All tests passed.
2026-10-19 03:04:59 +00:00
github-actions[bot]andGitHub b37ce3361d chore(deps): update module github.com/gofiber/contrib/v3/websocket to v1.2.8
Automated dependency update. All tests passed.
2026-10-18 03:05:31 +00:00
github-actions[bot]andGitHub a8901321aa chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-17 03:05:01 +00:00
github-actions[bot]andGitHub 5febeabbf2 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-15 03:05:20 +00:00
github-actions[bot]andGitHub d32f8f2be5 chore(deps): update module github.com/molecule-man/go-brrr to v1.3.0
Automated dependency update. All tests passed.
2026-10-14 03:05:03 +00:00
github-actions[bot]andGitHub 0e1d22f4e7 chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-13 03:10:48 +00:00
github-actions[bot]andGitHub 7d3b77806a chore(deps): update dependencies
Automated dependency update. All tests passed.
2026-10-12 03:15:59 +00:00
11 changed files with 345 additions and 105 deletions
+3
View File
@@ -5,3 +5,6 @@ dist/
coverage.out
CLAUDE.md
graphql-monitoring-proxy
# Lean proofs: local only, never committed
theorems/
+26 -8
View File
@@ -32,6 +32,9 @@ func escapeGlobPattern(s string) string {
return globEscaper.Replace(s)
}
// scanCountBatch is the SCAN COUNT hint used when counting keys.
const scanCountBatch = 1000
type RedisConfig struct {
ctx context.Context
client *redis.Client
@@ -163,12 +166,28 @@ func (c *RedisConfig) Clear() error {
// literally instead of being interpreted as a wildcard, which would
// otherwise undercount or overcount keys in admin-dashboard stats.
func (c *RedisConfig) CountQueries() (int64, error) {
pattern := escapeGlobPattern(c.prefix) + "*"
keys, err := c.client.Keys(c.ctx, pattern).Result()
if err != nil {
return 0, err
return c.scanCount(escapeGlobPattern(c.prefix) + "*")
}
// scanCount counts keys matching pattern with a cursor SCAN. KEYS builds the
// whole reply in memory on the server and blocks it, which spiked a shared
// Dragonfly to its memory limit when the prefix is empty.
func (c *RedisConfig) scanCount(pattern string) (int64, error) {
var (
cursor uint64
total int64
)
for {
keys, next, err := c.client.Scan(c.ctx, cursor, pattern, scanCountBatch).Result()
if err != nil {
return 0, fmt.Errorf("redis: scan at cursor %d: %w", cursor, err)
}
total += int64(len(keys))
cursor = next
if cursor == 0 {
return total, nil
}
}
return int64(len(keys)), nil
}
// CountQueriesWithPattern returns the number of keys owned by this cache
@@ -177,12 +196,11 @@ func (c *RedisConfig) CountQueries() (int64, error) {
// itself is left as given so a caller can still use glob metacharacters
// intentionally in the suffix it supplies.
func (c *RedisConfig) CountQueriesWithPattern(pattern string) (int, error) {
fullPattern := escapeGlobPattern(c.prefix) + pattern
keys, err := c.client.Keys(c.ctx, fullPattern).Result()
total, err := c.scanCount(escapeGlobPattern(c.prefix) + pattern)
if err != nil {
return 0, err
}
return len(keys), nil
return int(total), nil
}
// GetMemoryUsage returns an approximation of memory usage for Redis.
+82
View File
@@ -0,0 +1,82 @@
package libpack_cache_redis
import (
"context"
"fmt"
"net"
"strings"
"sync"
"testing"
"github.com/alicebob/miniredis/v2"
redis "github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type commandRecorder struct {
mu sync.Mutex
seen []string
}
func (r *commandRecorder) DialHook(next redis.DialHook) redis.DialHook {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
return next(ctx, network, addr)
}
}
func (r *commandRecorder) ProcessHook(next redis.ProcessHook) redis.ProcessHook {
return func(ctx context.Context, cmd redis.Cmder) error {
r.mu.Lock()
r.seen = append(r.seen, strings.ToLower(cmd.Name()))
r.mu.Unlock()
return next(ctx, cmd)
}
}
func (r *commandRecorder) ProcessPipelineHook(next redis.ProcessPipelineHook) redis.ProcessPipelineHook {
return next
}
func (r *commandRecorder) count(name string) int {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
for _, s := range r.seen {
if s == name {
n++
}
}
return n
}
// TestCount_UsesScanNotKeys guards against reintroducing KEYS, which builds
// the whole key list server-side and spiked a shared Dragonfly to its limit.
func TestCount_UsesScanNotKeys(t *testing.T) {
t.Parallel()
s, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(s.Close)
rc, err := New(&RedisClientConfig{RedisServer: s.Addr()})
require.NoError(t, err)
const total = 2500
for i := 0; i < total; i++ {
require.NoError(t, rc.Set(fmt.Sprintf("k%d", i), []byte("v"), 0))
}
rec := &commandRecorder{}
rc.client.AddHook(rec)
count, err := rc.CountQueries()
require.NoError(t, err)
assert.Equal(t, int64(total), count)
patternCount, err := rc.CountQueriesWithPattern("k1*")
require.NoError(t, err)
assert.Equal(t, 1111, patternCount, "k1, k10-k19, k100-k199, k1000-k1999")
assert.Zero(t, rec.count("keys"), "counting must not issue KEYS")
assert.GreaterOrEqual(t, rec.count("scan"), 2, "counting must issue SCAN")
}
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"testing"
"github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/assert"
"github.com/valyala/fasthttp"
)
// Pins theorems/Theorems/CoalesceAlias.lean: waiters never alias the primary's response buffer.
func TestSnapshotCoalescedResponse_OwnsBody(t *testing.T) {
app := fiber.New()
fctx := &fasthttp.RequestCtx{}
c := app.AcquireCtx(fctx)
defer app.ReleaseCtx(c)
c.Response().SetStatusCode(201)
c.Response().SetBody([]byte(`{"user":"alice"}`))
snap := snapshotCoalescedResponse(c)
c.Response().SetBody([]byte(`{"user":"bobby"}`)) // buffer reused by a later request
assert.Equal(t, 201, snap.StatusCode)
assert.Equal(t, `{"user":"alice"}`, string(snap.Body))
}
+21 -21
View File
@@ -2,23 +2,23 @@ module github.com/lukaszraczylo/graphql-monitoring-proxy
go 1.26.0
toolchain go1.27.2
toolchain go1.27.3
require (
github.com/MicahParks/keyfunc/v3 v3.8.2
github.com/VictoriaMetrics/metrics v1.44.1
github.com/alicebob/miniredis/v2 v2.39.0
github.com/alicebob/miniredis/v2 v2.40.0
github.com/avast/retry-go/v5 v5.0.0
github.com/goccy/go-json v0.11.2
github.com/gofiber/contrib/v3/websocket v1.2.7
github.com/gofiber/fiber/v3 v3.5.0
github.com/goccy/go-json v0.12.0
github.com/gofiber/contrib/v3/websocket v1.2.10
github.com/gofiber/fiber/v3 v3.6.1
github.com/gofrs/flock v0.13.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/gookit/goutil v0.8.1
github.com/gookit/goutil v0.8.6
github.com/gorilla/websocket v1.5.3
github.com/graphql-go/graphql v0.8.1
github.com/jackc/pgx/v5 v5.11.0
github.com/jackc/pgx/v5 v5.12.0
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9
github.com/lukaszraczylo/go-ratecounter v0.1.13
github.com/lukaszraczylo/go-simple-graphql v1.2.89
@@ -26,13 +26,13 @@ require (
github.com/redis/go-redis/v9 v9.23.0
github.com/sony/gobreaker/v2 v2.4.0
github.com/stretchr/testify v1.12.1
github.com/valyala/fasthttp v1.75.0
github.com/valyala/fasthttp v1.77.0
go.opentelemetry.io/otel v1.47.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0
go.opentelemetry.io/otel/sdk v1.47.0
go.opentelemetry.io/otel/trace v1.47.0
go.uber.org/automaxprocs v1.6.0
google.golang.org/grpc v1.84.0
google.golang.org/grpc v1.85.0
)
require (
@@ -43,17 +43,17 @@ require (
github.com/fasthttp/websocket v1.5.12 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gofiber/schema v1.8.8 // indirect
github.com/gofiber/utils/v2 v2.6.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect
github.com/gofiber/schema v1.9.2 // indirect
github.com/gofiber/utils/v2 v2.6.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.3 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.3 // indirect
github.com/mattn/go-colorable v0.1.16 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/molecule-man/go-brrr v1.2.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/molecule-man/go-brrr v1.3.1 // indirect
github.com/philhofer/fwd v1.2.1 // indirect
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 // indirect
github.com/tinylib/msgp v1.6.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
@@ -67,14 +67,14 @@ require (
go.opentelemetry.io/proto/otlp v1.11.1 // indirect
go.uber.org/atomic v1.12.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/crypto v0.57.0 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/net v0.62.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/term v0.47.0 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/time v0.16.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
+46 -46
View File
@@ -4,8 +4,8 @@ github.com/MicahParks/keyfunc/v3 v3.8.2 h1:eydEwk/pBAVrDIpmFfB/gkCcrp++xQ7YYXirr
github.com/MicahParks/keyfunc/v3 v3.8.2/go.mod h1:T4snFPe26GwMg45bBAdM5P6qWQyLxZHLwBhxR/9PnCs=
github.com/VictoriaMetrics/metrics v1.44.1 h1:OMyGrMkhI3hnq+IpJ1gZK9VhXcsOjUX1CoOFv//bJ0I=
github.com/VictoriaMetrics/metrics v1.44.1/go.mod h1:xDM82ULLYCYdFRgQ2JBxi8Uf1+8En1So9YUwlGTOqTc=
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/alicebob/miniredis/v2 v2.40.0 h1:6IgTQbvm2JiMYZ8yur/acNJzVjtSOWCelxXbk/LUBXc=
github.com/alicebob/miniredis/v2 v2.40.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=
github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q=
github.com/avast/retry-go/v5 v5.0.0 h1:kf1Qc2UsTZ4qq8elDymqfbISvkyMuhgRxuJqX2NHP7k=
@@ -21,25 +21,25 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.6 h1:NHZPxuUjJXQjX9itA785y/5NKSlI4HQ+S7noZsmI7hs=
github.com/fxamacker/cbor/v2 v2.9.6/go.mod h1:4EaZp6ClVunETjQ4GHmGNzysDK91Lr+r6Yz6tJqzVCw=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/goccy/go-json v0.11.2 h1:jdZv93Tt4ioR8yW1CoNsvSxrcZlCXAUU1aZXN7gpXUA=
github.com/goccy/go-json v0.11.2/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-json v0.12.0 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI=
github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY=
github.com/goccy/go-reflect v1.2.0 h1:O0T8rZCuNmGXewnATuKYnkL0xm6o8UNOJZd/gOkb9ms=
github.com/goccy/go-reflect v1.2.0/go.mod h1:n0oYZn8VcV2CkWTxi8B9QjkCoq6GTtCEdfmR66YhFtE=
github.com/gofiber/contrib/v3/websocket v1.2.7 h1:Na87WdkBkMl5wcf1MyD0q8neEmd1We57xSoJfIypmrk=
github.com/gofiber/contrib/v3/websocket v1.2.7/go.mod h1:L1ELWKh/Tn3sGEjQdLCmnb6HbroXsD83dcRCEwNXwOg=
github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8=
github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w=
github.com/gofiber/schema v1.8.8 h1:md5t65+Z9LttOuj7FdMcQ63D2+6GniT2ganLXwvTJM4=
github.com/gofiber/schema v1.8.8/go.mod h1:Hzf0L/fTPP1IwOAwKipKflm9C2722aebnSKJCMOgb1I=
github.com/gofiber/utils/v2 v2.6.1 h1:x3zsS/oUQz0ia2Zx8XXtG/7naR5a+/95FRO8mwQ5QQ4=
github.com/gofiber/utils/v2 v2.6.1/go.mod h1:tc9KqdRAQZRD/u0mN2d1ZHROGheSZv+0FcC9cg5OEdw=
github.com/gofiber/contrib/v3/websocket v1.2.10 h1:tUyRcHSH9GUsh6WO03Xn+Kjvt3850W6raF6WPngdJLE=
github.com/gofiber/contrib/v3/websocket v1.2.10/go.mod h1:O6MOrNHCVdCrrstdLrYmRIL0eJy2N87Wa/y2dszuxvQ=
github.com/gofiber/fiber/v3 v3.6.1 h1:wYjbgqpJm6VErIVLSGzGpX7lLZyTNgElsj8nQ1i+iFo=
github.com/gofiber/fiber/v3 v3.6.1/go.mod h1:+nxjBal3gw0EPf3gqK5EmtxaxTL01+zbSTagFletlDI=
github.com/gofiber/schema v1.9.2 h1:Gzw2PF1K5DpWgG2L51/9t9dkmxw4kqK6auKMvgD9mIU=
github.com/gofiber/schema v1.9.2/go.mod h1:219nYAgzIEBWl/aHCyhQtFoXBvfLjsC6+FI3DUN1+Ck=
github.com/gofiber/utils/v2 v2.6.3 h1:VlnOB89rFnonlXoYOkESOkXtIkAw4CBBwpBHu+/a/YQ=
github.com/gofiber/utils/v2 v2.6.3/go.mod h1:Mq3OXwyBPxORb3jPafy6Swki4/TJJW9TDPHeerXkXpg=
github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -50,24 +50,24 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/goutil v0.8.1 h1:kmNpOdJ/76KbNk8Xb7yTKfbQhUWY8T1nV1MrWI7dmZo=
github.com/gookit/goutil v0.8.1/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
github.com/gookit/goutil v0.8.6 h1:8LfEg9xVDdlQ1yWzk0seLslJCUPt8YnZQo5v0ycGs3E=
github.com/gookit/goutil v0.8.6/go.mod h1:vJS9HXctYTCLtCsZot5L5xF+O1oR17cDYO9R0HxBmnU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0 h1:5T5Ns3kMpWr+z3s5ZA0e7ljVyWNVNxK76sHPAxi5D1k=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.33.0/go.mod h1:oWNYw09cH8wl8fFgC5hKlBLxR4XfKy2KQpP8MJjeCcc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.12.0 h1:hWwax8YiGT0YiN50U1oIDn5kgR7KC2tG3rmavwpPkxY=
github.com/jackc/pgx/v5 v5.12.0/go.mod h1:u2Fi6no03HCSBYE+ZVdLxbwdu3xyi2vU+K/YNJioSds=
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9 h1:pL8B9mjv6RPUfKYYGm/uJ7QL6Ndf+z+OEl0qJE6KmEc=
@@ -82,10 +82,10 @@ github.com/mattn/go-colorable v0.1.16 h1:+2YjeAe7xP1OQbZW1jERPWWCDLlNqFNCvjiqGiA
github.com/mattn/go-colorable v0.1.16/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/molecule-man/go-brrr v1.2.0 h1:dOJU45BC3Gc2WXoxAW0rgCAgVuu2gruAOGncfieA0Jw=
github.com/molecule-man/go-brrr v1.2.0/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/molecule-man/go-brrr v1.3.1 h1:EwaJUzRIlp/dFmIpzq4J17CA5Zi5OHo2vOGmu09hF1w=
github.com/molecule-man/go-brrr v1.3.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4=
github.com/philhofer/fwd v1.2.1 h1:UtpS73q8bFgy/yH3gCq8LSNvj5gv/FowNOiWZa7uKwQ=
github.com/philhofer/fwd v1.2.1/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
@@ -93,8 +93,8 @@ github.com/redis/go-redis/v9 v9.23.0 h1:/TTVdJa8BRNsybwwGGnmiYktHNlUXtbOQxlWpHBP
github.com/redis/go-redis/v9 v9.23.0/go.mod h1:EWP2UUk+XDaI1s5nQB5mablZN6vccXlmBQTyfnF87D0=
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761 h1:McifyVxygw1d67y6vxUqls2D46J8W9nrki9c8c0eVvE=
github.com/savsgio/gotils v0.0.0-20250924091648-bce9a52d7761/go.mod h1:Vi9gvHvTw4yCUHIznFl5TPULS7aXwgaTByGeBY75Wko=
github.com/shamaton/msgpack/v3 v3.2.3 h1:0r0aZtrXDFOPpwWJTEOpFq+Ih/JmMLm0pqLN5mSilrw=
github.com/shamaton/msgpack/v3 v3.2.3/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
github.com/shamaton/msgpack/v3 v3.2.6 h1:i8I++0ehiIO8yK4+ZU6j5MIWFz5e8J6EeJvdM/qeyTw=
github.com/shamaton/msgpack/v3 v3.2.6/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo=
github.com/sony/gobreaker/v2 v2.4.0 h1:g2KJRW1Ubty3+ZOcSEUN7K+REQJdN6yo6XvaML+jptg=
github.com/sony/gobreaker/v2 v2.4.0/go.mod h1:pTyFJgcZ3h2tdQVLZZruK2C0eoFL1fb/G83wK1ZQl+s=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -106,8 +106,8 @@ github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.75.0 h1:W3xrvNJHG60j2ZCjxpP8kkB+oPG8kh9/g1n4+3xUwNA=
github.com/valyala/fasthttp v1.75.0/go.mod h1:JiVkPRZU124PuPsreV/s9dZRgG2lRK7wNEikdQ0Rk4s=
github.com/valyala/fasthttp v1.77.0 h1:6mxBJdHabRmU4Cmw79Uqhgvjzsp6pjOaQk2t8COflzo=
github.com/valyala/fasthttp v1.77.0/go.mod h1:m8oTPC8riDDky07invpl+Gz3RixnAN5BbwyUuVB5H2o=
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
@@ -146,30 +146,30 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e h1:e789sLZjeTdc02ZCQGTSzHOu0fR73+dwPQVKUp67Z88=
google.golang.org/genproto/googleapis/api v0.0.0-20261026165753-2ffab32bce7e/go.mod h1:vHV3wJM9WZyfV7GyB+OQGq5dWVrFwCDxapPK4WlRD88=
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k=
google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.85.0 h1:BPBCfuVRQ1qvBhKDOq9uvQllMvO59rKsYhXpp7Dd8vw=
google.golang.org/grpc v1.85.0/go.mod h1:0rQrOD58yjal/0EaVbGBANindixsCZNQPDV3VKtaKuQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+12 -5
View File
@@ -740,11 +740,7 @@ func performProxyRequest(c fiber.Ctx, proxyURL string) error {
}, proxyErr
}
return &CoalescedResponse{
Body: c.Response().Body(),
StatusCode: c.Response().StatusCode(),
// Headers intentionally left nil; not populated or read anywhere.
}, nil
return snapshotCoalescedResponse(c), nil
})
// Check for error from rc.Do (though it typically returns nil)
@@ -773,6 +769,17 @@ func performProxyRequest(c fiber.Ctx, proxyURL string) error {
return performProxyRequestCore(c, proxyURL, cacheKey)
}
// snapshotCoalescedResponse captures the primary's response for waiters. It
// copies the body because fasthttp reuses the buffer once the primary handler
// returns, while waiters can still be reading.
func snapshotCoalescedResponse(c fiber.Ctx) *CoalescedResponse {
return &CoalescedResponse{
Body: bytes.Clone(c.Response().Body()),
StatusCode: c.Response().StatusCode(),
// Headers intentionally left nil; not populated or read anywhere.
}
}
// performProxyRequestCore executes the proxy request with retries and circuit breaker
// This is the core implementation used by both direct calls and coalesced requests
func performProxyRequestCore(c fiber.Ctx, proxyURL string, cacheKey string) error {
+27 -19
View File
@@ -148,31 +148,39 @@ func (rb *RetryBudget) refill() {
now := time.Now().UnixNano()
last := rb.lastRefill.Load()
// Calculate elapsed time in seconds
elapsed := float64(now-last) / float64(time.Second)
tokensToAdd, newLast := refillStep(tokensPerSecond, last, now)
if tokensToAdd <= 0 {
return
}
if rb.lastRefill.CompareAndSwap(last, newLast) {
for {
current := rb.currentTokens.Load()
newValue := current + tokensToAdd
if newValue > maxTokens {
newValue = maxTokens
}
// Calculate tokens to add
tokensToAdd := int64(elapsed * tokensPerSecond)
if tokensToAdd > 0 {
// Update last refill time
if rb.lastRefill.CompareAndSwap(last, now) {
// Add tokens, capped at maxTokens
for {
current := rb.currentTokens.Load()
newValue := current + tokensToAdd
if newValue > maxTokens {
newValue = maxTokens
}
if rb.currentTokens.CompareAndSwap(current, newValue) {
break
}
if rb.currentTokens.CompareAndSwap(current, newValue) {
break
}
}
}
}
// refillStep returns the whole tokens earned between last and now (ns) and the
// clock to store. The clock advances only by the time those tokens pay for, so
// the fractional remainder carries into the next tick.
func refillStep(tokensPerSecond float64, last, now int64) (int64, int64) {
if tokensPerSecond <= 0 || now <= last {
return 0, last
}
tokens := int64(float64(now-last) / float64(time.Second) * tokensPerSecond)
if tokens <= 0 {
return 0, last
}
return tokens, last + int64(float64(tokens)/tokensPerSecond*float64(time.Second))
}
// GetStats returns current statistics
func (rb *RetryBudget) GetStats() map[string]any {
totalAttempts := rb.totalAttempts.Load()
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// Pins theorems/Theorems/RetryRefill.lean: total_added(15 tps, 100ms ticks, 10s) = 150.
func TestRefillStep_KeepsFractionalTokens(t *testing.T) {
tests := []struct {
name string
tps float64
want int64
}{
{"15 tps", 15, 150},
{"25 tps", 25, 250},
{"5 tps (sub-token per tick)", 5, 50},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tick := 100 * time.Millisecond
var last, total int64
now := int64(tick)
for i := 0; i < 100; i++ {
added, newLast := refillStep(tt.tps, last, now)
total += added
last = newLast
now += int64(tick)
}
assert.Equal(t, tt.want, total)
})
}
}
func TestRefillStep_NoElapsedNoChange(t *testing.T) {
added, last := refillStep(10, 1000, 1000)
assert.Zero(t, added)
assert.Equal(t, int64(1000), last)
}
func TestRefillStep_ZeroRateNeverAdds(t *testing.T) {
added, last := refillStep(0, 0, int64(time.Hour))
assert.Zero(t, added)
assert.Zero(t, last)
}
+11 -6
View File
@@ -57,17 +57,23 @@ func (r *RPSTracker) Shutdown() {
// sample calculates RPS since last sample
func (r *RPSTracker) sample() {
now := time.Now()
nowNano := now.UnixNano()
r.sampleCount()
}
// sampleCount performs one sample and returns the requests it consumed.
func (r *RPSTracker) sampleCount() int64 {
nowNano := time.Now().UnixNano()
currentCount := r.lastCount.Load()
lastSampleNano := r.lastSampleTime.Load()
if lastSampleNano == 0 {
r.lastSampleTime.Store(nowNano)
return
return 0
}
// Swap, not Load+Store(0): requests recorded between the two would be lost.
currentCount := r.lastCount.Swap(0)
elapsed := float64(nowNano-lastSampleNano) / float64(time.Second)
if elapsed > 0 {
rps := float64(currentCount) / elapsed
@@ -75,9 +81,8 @@ func (r *RPSTracker) sample() {
r.currentRPS.Store(uint64(rps * 100))
}
// Reset for next sample
r.lastCount.Store(0)
r.lastSampleTime.Store(nowNano)
return currentCount
}
// GetCurrentRPS returns the current requests per second
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"context"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
// Pins theorems/Theorems/RpsSample.lean: no recorded request is dropped by sample().
func TestRPSTracker_SampleKeepsConcurrentRequests(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tr := NewRPSTracker(ctx)
tr.Shutdown() // stop the background sampler; sample() is driven by the test
const writers, perWriter = 8, 20000
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < perWriter; j++ {
tr.RecordRequest()
}
}()
}
var sampled int64
done := make(chan struct{})
go func() { wg.Wait(); close(done) }()
loop:
for {
select {
case <-done:
break loop
default:
sampled += tr.sampleCount()
}
}
sampled += tr.sampleCount()
assert.Equal(t, int64(writers*perWriter), sampled)
}