feat(19.3-01): share rate limits across instances through cooler's Redis store

- new module cooler: Install, Handle, OpenRedis (no URL in errors), RateLimitStore
  (atomic fixed-window Lua script with MemoryStore semantics, per-instance fallback)
- serve installs cooler and publishes the Redis surf.Store before BuildRouter;
  BuildRouter looks up a published Store, route:list keeps the memory store
- config keys http.rate_limit.store and redis.url; go-redis v9.23.0
- cooler README, root table row, surf README and rate-limiting, configuration
  and scaling docs
This commit is contained in:
Jakub Zych
2026-10-15 17:34:39 +02:00
parent 77dd11c804
commit fa4d89d0b9
15 changed files with 777 additions and 10 deletions

View File

@@ -16,6 +16,7 @@ import (
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/cooler"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/party"
@@ -53,6 +54,11 @@ func ServeCommand(app *backpack.App, plugins []party.Plugin) bonfire.Command {
return err
}
defer bucket.Close()
coolerHandle, err := installCooler(ctx, app)
if err != nil {
return err
}
defer coolerHandle.Close()
r, err := BuildRouter(app, plugins)
if err != nil {
return err
@@ -139,6 +145,25 @@ func publishUploads(ctx context.Context, app *backpack.App) (*blob.Bucket, error
return bucket, nil
}
// installCooler installs cooler and, when http.rate_limit.store is redis,
// publishes its Redis-backed Store (falling back to a per-instance
// MemoryStore while Redis does not answer) so BuildRouter's limiter shares
// counters across instances. Only serve calls it: route:list never dials Redis.
func installCooler(ctx context.Context, app *backpack.App) (*cooler.Handle, error) {
h, err := cooler.Install(ctx, app)
if err != nil {
return nil, err
}
if h.RateLimitRedis() {
// longest bucket decay is 1 minute; sweep at 2x (as BuildRouter)
if err := app.Publish[Store](h.RateLimitStore(NewMemoryStore(2 * time.Minute))); err != nil {
_ = h.Close()
return nil, fmt.Errorf("surf: %w", err)
}
}
return h, nil
}
// adminCreateCommand is the command the startup hints name for creating the
// first backend administrator.
const adminCreateCommand = "summer admin:create --email you@example.com --superuser"