Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- Add an optional `now` timestamp argument to the ETS `:fix_window` backend's `hit`, `inc`, and `get` (and the generated `use Hammer` wrappers), defaulting to the current time. Lets callers drive deterministic windows in tests, or supply a custom clock, without sleeping across wall-clock boundaries.

## 7.4.0 - 2026-05-19

- Add `:fix_window_per_key` algorithm for ETS and Atomic backends — a fixed-window variant whose window is anchored to first hit per key instead of a globally-aligned wall-clock epoch. Same one-entry-per-key memory profile as `:fix_window`. The 2x boundary burst is still possible per key, but boundaries are no longer globally synchronized. (#181)
Expand Down
21 changes: 21 additions & 0 deletions lib/hammer/ets.ex
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,27 @@ defmodule Hammer.ETS do
end
end

# Same as hit/4 but with an explicit `now` (timestamp in the backend's
# time unit), letting callers drive deterministic windows in tests or
# supply a custom clock. Only generated for algorithms that accept it.
if function_exported?(@algorithm, :hit, 6) do
def hit(key, scale, limit, increment, now) do
@algorithm.hit(@table, key, scale, limit, increment, now)
end
end

if function_exported?(@algorithm, :inc, 4) do
def inc(key, scale, increment \\ 1) do
@algorithm.inc(@table, key, scale, increment)
end
end

if function_exported?(@algorithm, :inc, 5) do
def inc(key, scale, increment, now) do
@algorithm.inc(@table, key, scale, increment, now)
end
end

if function_exported?(@algorithm, :set, 4) do
def set(key, scale, count) do
@algorithm.set(@table, key, scale, count)
Expand All @@ -151,6 +166,12 @@ defmodule Hammer.ETS do
end
end

if function_exported?(@algorithm, :get, 4) do
def get(key, scale, now) do
@algorithm.get(@table, key, scale, now)
end
end

if function_exported?(@algorithm, :get, 2) do
def get(key, scale) do
@algorithm.get(@table, key)
Expand Down
20 changes: 11 additions & 9 deletions lib/hammer/ets/fix_window.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ defmodule Hammer.ETS.FixWindow do
key :: term(),
scale :: pos_integer(),
limit :: pos_integer(),
increment :: pos_integer()
increment :: pos_integer(),
now :: non_neg_integer()
) :: {:allow, non_neg_integer()} | {:deny, non_neg_integer()}
def hit(table, key, scale, limit, increment) do
now = ETS.now()
def hit(table, key, scale, limit, increment, now \\ ETS.now()) do
window = div(now, scale)
full_key = {key, window}
expires_at = (window + 1) * scale
Expand All @@ -117,11 +117,12 @@ defmodule Hammer.ETS.FixWindow do
table :: atom(),
key :: term(),
scale :: pos_integer(),
increment :: pos_integer()
increment :: pos_integer(),
now :: non_neg_integer()
) ::
non_neg_integer()
def inc(table, key, scale, increment) do
window = div(ETS.now(), scale)
def inc(table, key, scale, increment, now \\ ETS.now()) do
window = div(now, scale)
full_key = {key, window}
expires_at = (window + 1) * scale
ETS.update_counter(table, full_key, increment, expires_at)
Expand All @@ -142,9 +143,10 @@ defmodule Hammer.ETS.FixWindow do
@doc """
Returns the count of requests for a given key within the last <scale> seconds.
"""
@spec get(table :: atom(), key :: term(), scale :: pos_integer()) :: non_neg_integer()
def get(table, key, scale) do
window = div(ETS.now(), scale)
@spec get(table :: atom(), key :: term(), scale :: pos_integer(), now :: non_neg_integer()) ::
non_neg_integer()
def get(table, key, scale, now \\ ETS.now()) do
window = div(now, scale)
full_key = {key, window}

case :ets.lookup(table, full_key) do
Expand Down
20 changes: 20 additions & 0 deletions test/hammer/ets/fix_window_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,26 @@ defmodule Hammer.ETS.FixWindowTest do
assert {:deny, _retry_after} = FixWindow.hit(table, key, scale, limit, 1)
end

test "with an explicit now, windows are deterministic without sleeping", %{table: table} do
key = "clock-key"
scale = 1_000
limit = 2

# Pin three hits to one window, then jump to the next window by passing
# an explicit `now` - no wall-clock sleep needed.
now = 1_000_000

assert {:allow, 1} = FixWindow.hit(table, key, scale, limit, 1, now)
assert {:allow, 2} = FixWindow.hit(table, key, scale, limit, 1, now)
assert {:deny, retry_after} = FixWindow.hit(table, key, scale, limit, 1, now)
assert retry_after == scale - rem(now, scale)

# Same window: get/4 reads the pinned count; the next window is fresh.
assert FixWindow.get(table, key, scale, now) == 3
assert {:allow, 1} = FixWindow.hit(table, key, scale, limit, 1, now + scale)
assert FixWindow.get(table, key, scale, now) == 3
end

test "with custom increment", %{table: table} do
key = "cost-key"
scale = :timer.seconds(1)
Expand Down