fix(ets): carry the sub-token refill remainder across hits - #197
Merged
epinault merged 1 commit intoSep 2, 2026
Merged
Conversation
TokenBucket.hit/5 stamped last_update = now on every allow, so any refill smaller than one whole token was discarded. A caller hitting faster than one token-period credited trunc(0.x) == 0 tokens on every hit and drained without ever refilling; a caller paced at its own nominal refill rate decayed a fraction of a token per hit until it was denied. Advance the clock only by the time whose tokens were actually credited, so the remainder carries. When the refill overflows the bucket the surplus is legitimately discarded and the clock snaps to now, otherwise a long-idle bucket banks unbounded credit -- but only when tokens actually accrued: a bucket merely sitting at capacity with new_tokens == 0 has overflowed nothing, and that elapsed time is still owed to the level the hit draws down. Deterministic tests seed last_update at a known offset and assert the value written back. Three of the four fail against the previous implementation; the paced-caller test is denied at step 11 of 60.
3 tasks
epinault
added a commit
that referenced
this pull request
Sep 2, 2026
- Bump version 7.4.1 -> 7.5.0 in mix.exs - Promote Unreleased changelog section to 7.5.0 (#194, #197, #198) Minor bump: the release adds the mix hammer.install Igniter task and carries two TokenBucket ETS fixes, one of which changes the deny wait from a flat 1000ms to the real time-to-next-token. Claude-Session: https://claude.ai/code/session_011dH6kRtZqUkVpoGo79CKYG Co-authored-by: epinault <dev@pinault-family.us>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Hammer.ETS.TokenBucket.hit/5stampslast_update = nowon every allowed hit, butnew_tokensis truncated to a whole integer. Any refill smaller than one whole token is therefore discarded, on every hit, permanently.Two callers are affected:
refill_rate: 55, hits every 5ms each credittrunc(5 * 55 / 1000) == 0tokens. The bucket drains and only recovers because the deny branch doesn't write.refill_rate: 55(one token per ~18.18ms), hits every 18ms each credit exactly 1 token but advance the clock a full 18ms, losing ~0.18ms every time. The level decays until the caller is denied at a rate it was entitled to sustain.The fix
Advance the clock only by the time whose tokens were actually credited, so the remainder carries into the next hit:
No storage format change, no new field.
The exception is an overflowing refill. When the bucket fills to capacity the surplus is legitimately discarded, so the clock must snap to
now— otherwise a long-idle bucket banks unbounded credit and the next burst is unbounded with it. That applies only when tokens actually accrued. A bucket merely sitting at capacity withnew_tokens == 0has overflowed nothing, and that elapsed time is still owed to the level the hit is about to draw down. Guarding oncurrent_tokens == capacityalone silently throws it away, so the guard iscurrent_tokens == capacity and new_tokens > 0.On
clean/1Carrying the remainder means the stored timestamp deliberately lags
now. Two bounds keep that safe, and both are now pinned by a test:now, because the credited time is at most the elapsed time (trunc(new_tokens * 1000 / refill_rate) <= elapsed, always);refill_rate >= 1.clean/1compares againstnow - key_older_than, and the documented guidance for that option is hours, so a sub-second lag cannot cause an active row to be reaped.Tests
Four new tests seed
last_updateat a known offset and assert the value written back, so they are deterministic rather than throughput measurements — the stored timestamp derives fromlast_updateplus credited time, never from when the test happened to run.Three of them fail against
master:carries the sub-token remainder instead of discarding itdoes not reset the clock when a full bucket accrued no new tokensa caller paced at the nominal refill rate does not starve— denied at step 11 of 60 onmasterThe fourth,
the stored clock stays between last_update and now, passes onmastertoo. It is a guarantee pin for the bounds above, not regression proof.Test plan
mix test— 147 tests, 0 failures (142 onmasterbefore these 5 were added)master, 3 of 4 fail as describedNotes
Not addressed here, happy to open issues if useful:
Hammer.Atomic.TokenBucketand both leaky buckets still useSystem.system_time(:second)and carry the original whole-second bug from ETS TokenBucket: whole-second time granularity caps effective throughput below configured refill_rate when refill_rate > capacity #192. The atomic one is not a drop-in fix — it packs timestamp and fill into one 64-bit word at 32 bits each, and ms-since-epoch needs ~41.{:deny, 1000}where the other algorithms return a real computed wait. That became computable once the clock moved to milliseconds, but it is a behaviour change, so it seemed worth asking about rather than bundling in here.