diff --git a/CHANGELOG.md b/CHANGELOG.md index ac9a6b6..f75e466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 7.5.0 - 2026-09-02 +- Fix race in Atomic `TokenBucket` and `LeakyBucket` where a newly created bucket was published in ETS before its atomic was initialized, so a concurrent hit could read an uninitialized bucket. With `refill_rate: 0` this lost a hit. The atomic is now initialized before it is inserted. (#202) - Fix `TokenBucket` ETS backend discarding the sub-token refill remainder on every allowed hit. Callers hitting faster than one token period never refilled, and callers paced at exactly the refill rate slowly starved. The stored clock now advances only by the time whose tokens were credited, so the remainder carries into the next hit. (#197) - Fix `TokenBucket` ETS backend returning a flat `{:deny, 1000}`; it now returns the real time in milliseconds until the bucket holds enough tokens to pay the cost, consistent with the other algorithms. **Behaviour change:** denied callers that sleep on this value will now retry much sooner (e.g. 19ms at `refill_rate: 55` instead of 1000ms). Code that converts it to a `retry-after` header should round up and floor at 1 second; the tutorial examples were updated accordingly. (#198) - Add `mix hammer.install` Igniter task (`mix igniter.install hammer`) that generates a `MyApp.RateLimit` module and adds it to the supervision tree. Supports `--backend ets|atomic|redis`; `redis` also adds the `hammer_backend_redis` dependency. Igniter is an optional dependency. (#157) diff --git a/lib/hammer/atomic/leaky_bucket.ex b/lib/hammer/atomic/leaky_bucket.ex index 37c1a19..7593021 100644 --- a/lib/hammer/atomic/leaky_bucket.ex +++ b/lib/hammer/atomic/leaky_bucket.ex @@ -132,14 +132,14 @@ defmodule Hammer.Atomic.LeakyBucket do do_hit(atomic, now, leak_rate, capacity, cost) [] -> + # Initialize the atomic (empty bucket at current time) before + # publishing it in ETS, so a concurrent lookup never sees an + # uninitialized zero value. atomic = :atomics.new(2, signed: false) + :atomics.put(atomic, 1, pack(now, 0)) + :atomics.put(atomic, 2, now) - if :ets.insert_new(table, {key, atomic}) do - # Initialize with empty bucket at current time - initial_packed = pack(now, 0) - :atomics.put(atomic, 1, initial_packed) - :atomics.put(atomic, 2, now) - end + :ets.insert_new(table, {key, atomic}) hit(table, key, leak_rate, capacity, cost) end diff --git a/lib/hammer/atomic/token_bucket.ex b/lib/hammer/atomic/token_bucket.ex index 9b22f6a..485f618 100644 --- a/lib/hammer/atomic/token_bucket.ex +++ b/lib/hammer/atomic/token_bucket.ex @@ -136,13 +136,15 @@ defmodule Hammer.Atomic.TokenBucket do do_hit(atomic, now, refill_rate, capacity, cost) [] -> + # Initialize the atomic before publishing it in ETS. Once insert_new + # succeeds, other processes can look it up immediately, and reading + # an uninitialized (zero) packed value would look like an empty bucket + # at timestamp 0. atomic = :atomics.new(2, signed: false) + :atomics.put(atomic, 1, pack(now, capacity)) + :atomics.put(atomic, 2, now) - if :ets.insert_new(table, {key, atomic}) do - initial_packed = pack(now, capacity) - :atomics.put(atomic, 1, initial_packed) - :atomics.put(atomic, 2, now) - end + :ets.insert_new(table, {key, atomic}) hit(table, key, refill_rate, capacity, cost) end