Skip to content

feat: Backend — one limit shared across processes, any KV store - #15

Merged
christiangda merged 3 commits into
mainfrom
feat/pluggable-backend
Aug 25, 2026
Merged

feat: Backend — one limit shared across processes, any KV store#15
christiangda merged 3 commits into
mainfrom
feat/pluggable-backend

Conversation

@christiangda

Copy link
Copy Markdown
Contributor

Builds on #14 (merged), which supplies the WithLimiterFactoryForKey seam.

The gap

Storage holds limiters in this process. There was no seam for holding the
count somewhere else — so a limit shared across instances was out of reach
without abandoning this package.

type Backend interface {
	Take(ctx context.Context, key string, limit Limit, cost int) (Decision, error)
}

Implement it over Valkey, Redis, DynamoDB, Postgres — or use the bundled
MemoryBackend — and every process shares one budget.

Why Take-shaped and not Get/Set-shaped

The obvious design is a small key-value interface with the token arithmetic in
this package. That works in one process and is a race everywhere else:

read (tokens, ts) → refill by elapsed time → compare → write back

Two processes read the same state, both decide they may proceed, both write, and
the limit silently becomes 2×. Making it safe needs a transaction with a
retry loop on a key that is contended by definition, or a server-side script —
neither expressible through Get/Set.

So the decision has to run where the state lives, and the interface has to be
the decision. That is also precisely why Storage cannot do this: it is a
container of Limiter values in this process, and its Load/Store shape is
the Get/Set shape.

You want… Extension point
a different in-process container Storage
a different algorithm Limiter
a shared limit Backend

What BackendLimiter adds

  • A local fallback. Neither answer to "the datastore is down" is acceptable
    alone: refusing turns a blip into a total outage; allowing deletes the limiter
    exactly when it is needed. With a fallback there is nothing to choose between —
    an outage degrades to per-process limiting, which is what you had before adding
    a backend at all.
  • A circuit breaker. Falling back is only half a fallback. Without one,
    every request during an outage pays a failed round trip before reaching the
    local answer it was always going to get.
  • A degraded signal. A limiter silently enforcing N× the intended limit is
    invisible from a request. Alert on the state, not the error rate.

MemoryBackend is a window counter on purpose

Even though this package has a better in-process limiter. It is the same
algorithm a datastore backend runs
, so moving between them does not move the
behaviour underneath you. RateLimiter stays the right choice when you want the
best in-process limiter — documented in TOKEN_BUCKET.md with the trade-off
table.

Two bugs the tests found — in code I had already written and believed

  1. The single-prober guard did not work. It used a mutex with
    defer Unlock(), which releases when shouldAsk returns — nanoseconds
    later, long before the backend answers. Every caller acquired it in turn.
    Measured: 20 of 20 racers reached the backend, the exact thundering herd
    the guard exists to prevent. Now a CAS held across the call.
  2. A failed probe never released the slot, so the breaker wedged shut for
    ever: the backend was never re-tested and the limiter stayed degraded
    permanently, including long after recovery. Worse than the outage, because
    it does not end when the outage does.

Both have tests now, both verified to fail by reverting the fix.

Benchmarks drove an API addition

BackendLimiter.Allow measured 391 ns/op against a backend answering in
100 ns — all of it context.WithTimeout arming a timer per request.

with a timeout:  391 ns/op
without:         110 ns/op

WithoutBackendTimeout removes the deadline for in-process backends, documented
as never for a network backend, where the deadline is what bounds a hung
datastore.

Tests

Every one verified to fail by mutating the code it guards — 7 mutations.
Coverage 100.0%, the level this repo was at before this work.

Docs — all of them

File
docs/BACKENDS.md new; includes a ~25-line Valkey implementation
doc.go rewritten — it claimed distributed limiting was "out of scope for the bundled types", which this makes false
docs/TOKEN_BUCKET.md "deliberately out of scope" replaced with the real trade-off table
docs/CUSTOM_STORAGE.md routes readers to BACKENDS.md instead of only explaining what not to do
docs/MIGRATION.md notes the Backend release is additive
README.md Backends section; the "single-process only" claim qualified

All four cross-link; link check clean. Runnable: go run ./examples/backend.

Dependencies and toolchain

golang.org/x/time is already at v0.15.0, the latest — go get -u ./... and
go mod tidy produce no change, and the dependency list stays one entry.

The go directive moves 1.26.0 → 1.27.0, which is what CI actually builds
with (go-version-file: ./go.mod) and matches the consumers. go fix then had
one thing to say — a three-line clamp collapsed to max(...), which happened to
be the last statement no test reached, taking coverage back to 100%.

Reviewers: raising the go directive raises the minimum Go version for
everyone importing this library. It is the one part of this PR that is not
purely additive. Everything else keeps every existing call working — Option
was deliberately not made generic, or every current WithClock(now) would
need explicit instantiation.

Unrelated finding

CI runs go-test-coverage --config=./.testcoverage.yml and that file does not
exist in the repo
. It exits 0 because the step pipes through tee without
pipefail, so the coverage gate silently passes without ever running. Not
touched here — adding the config would start enforcing a threshold nobody has
chosen.

🤖 Generated with Claude Code

Storage holds limiters IN THIS PROCESS. There was no seam for holding the
COUNT somewhere else, so a limit shared across instances was out of reach
without abandoning this package.

Backend is that seam:

    type Backend interface {
        Take(ctx context.Context, key string, limit Limit, cost int) (Decision, error)
    }

Implement it over Valkey, Redis, DynamoDB, Postgres — or use the bundled
MemoryBackend — and every process shares one budget.

WHY IT IS TAKE-SHAPED AND NOT GET/SET-SHAPED. The obvious design is a small
key-value interface — Get, Set, Incr — with the token arithmetic in this
package. That works in one process and is a race everywhere else: the update is

    read (tokens, ts) -> refill by elapsed time -> compare -> write back

a read-modify-write. Two processes read the same state, both decide they may
proceed, both write, and the limit silently becomes 2x. Making it safe needs a
transaction with a retry loop on a key that is contended by definition, or a
server-side script; neither is expressible through Get/Set. So the decision has
to run where the state lives, and the interface has to be the DECISION.

That is also precisely why Storage cannot do this: Storage is a container of
Limiter values in this process, and its Load/Store shape IS the Get/Set shape.

WHAT BackendLimiter ADDS, and why it is here rather than copied into every
consumer:

  - A LOCAL FALLBACK. Neither answer to "the datastore is down" is acceptable
    alone: refusing turns a blip into a total outage, allowing deletes the
    limiter exactly when it is needed. With a fallback there is nothing to
    choose between -- an outage degrades to per-process limiting, which is what
    you had before adding a backend at all.
  - A CIRCUIT BREAKER. Falling back is only half a fallback. Without one, every
    request during an outage pays a failed round trip before reaching the local
    answer it was always going to get: the limiter keeps working and makes the
    whole service slower by the timeout.
  - A DEGRADED SIGNAL. A limiter silently enforcing N x the intended limit is
    invisible from a request. Alert on the state, not the error rate.

MemoryBackend is deliberately a sliding-window counter rather than a token
bucket, even though this package has a better in-process limiter. It is the same
algorithm a datastore backend runs, so moving between them does not move the
behaviour underneath you. RateLimiter remains the right choice when you want the
best in-process limiter.

TWO BUGS THE TESTS FOUND, both in code I had already written and believed:

  - The single-prober guard used a mutex with `defer Unlock()`, which releases
    when shouldAsk RETURNS -- nanoseconds later, long before the backend
    answers. Every caller acquired it in turn. Measured: 20 of 20 racers
    reached the backend, which is the thundering herd the guard exists to
    prevent. It is a CAS on an atomic held across the call now.
  - A FAILED probe did not release the slot, so the breaker wedged shut for
    ever: the backend was never re-tested and the limiter stayed degraded
    permanently, including long after recovery. Worse than the outage, because
    it does not end when the outage does.

Benchmarks drove one API addition. BackendLimiter.Allow measured 391 ns/op
against a backend that answers in 100 ns, all of it context.WithTimeout arming
a timer per request. WithoutBackendTimeout removes the deadline for in-process
backends: 110 ns/op. It is documented as never for a network backend, where the
deadline is what bounds a hung datastore.

Every test verified to fail by mutating the code it guards. Coverage 99.7%.

Docs: new docs/BACKENDS.md; doc.go rewritten (it claimed distributed limiting
was out of scope for the bundled types, which this makes false);
TOKEN_BUCKET.md's "deliberately out of scope" section replaced with the real
trade-off table; CUSTOM_STORAGE.md now routes readers to BACKENDS.md instead of
only explaining what not to do; MIGRATION.md notes this release is additive.
All four cross-link. Runnable example in examples/backend.

Purely additive: every existing call keeps working.
go.mod moves from 1.26.0 to 1.27.0. CI resolves its toolchain from
`go-version-file: ./go.mod`, so this is what the build actually runs on, and it
matches the toolchain the consumers of this library are already using.

golang.org/x/time is already at v0.15.0, the latest; `go get -u ./...` and
`go mod tidy` produce no change. The dependency list stays one entry, which is
worth keeping that way.

`go fix ./...` had one thing to say, in code added by the previous commit: a
three-line clamp collapsed to `max(...)`. That branch was the last statement in
the package no test reached, so coverage goes back to 100.0% -- the level this
repo was at before the Backend work, and the reason to check rather than assume
it was still true.

NOTE FOR REVIEWERS: raising the `go` directive raises the minimum Go version for
everyone who imports this library. That is a deliberate choice rather than a
side effect of the tooling, and it is the one part of this change that is not
purely additive.
@christiangda christiangda self-assigned this Aug 25, 2026
Two changes, one of them undoing a mistake I made in the previous commit.

REVERT THE go DIRECTIVE to 1.26.0. Bumping it to 1.27.0 broke CodeQL:

    go: go.mod requires go >= 1.27.0 (running go 1.26.6; GOTOOLCHAIN=local)
    Extraction failed for all discovered Go projects.

The autobuilder runs whatever Go the runner image ships with GOTOOLCHAIN=local,
so it cannot fetch a newer toolchain and the analysis simply fails.

But the failure is the smaller half of the argument. The bump was justified as
"this is what CI builds with, and it matches the consumers" -- and CodeQL is a
counter-example to the first half, while the second is not a reason that binds
anybody else. Nothing in this package needs 1.27: `go vet` with the directive at
1.26 reports no use of a newer standard-library symbol, and the full suite
passes at 100% coverage. Raising the minimum Go version of a PUBLIC library
excludes every consumer still on the previous release, and Go supports the two
most recent majors. Doing that for alignment rather than for a feature is a cost
paid by other people for our convenience.

The `max(...)` rewrite `go fix` produced stays: it predates 1.27 and it is why
coverage reached 100%.

PIN GO IN THE CODEQL WORKFLOW anyway, from go.mod, before codeql-action/init.
The workflow is fragile independently of this revert: it analyses with whatever
Go the runner happens to have that week, so the day the directive legitimately
moves, this breaks again. And it breaks illegibly -- the reported error is "We
were unable to automatically build your code", which points nowhere near a
version mismatch. This is the same class of problem as the pr.yaml coverage step
noted in the PR: a workflow that depends on an unstated assumption about the
runner.

Verified at 1.26.0: go vet clean (including the stdversion check, which is what
proves no 1.27-only symbol is used), go fix -diff clean, race tests green,
coverage 100.0%.
@christiangda
christiangda merged commit 5da2a9d into main Aug 25, 2026
5 checks passed
@christiangda
christiangda deleted the feat/pluggable-backend branch August 25, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant