diff --git a/CHANGELOG.md b/CHANGELOG.md index 667f4d8..a041140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/lib/hammer/ets.ex b/lib/hammer/ets.ex index 2f4dd27..65af9a8 100644 --- a/lib/hammer/ets.ex +++ b/lib/hammer/ets.ex @@ -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) @@ -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) diff --git a/lib/hammer/ets/fix_window.ex b/lib/hammer/ets/fix_window.ex index 5d7147c..9d3e6a0 100644 --- a/lib/hammer/ets/fix_window.ex +++ b/lib/hammer/ets/fix_window.ex @@ -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 @@ -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) @@ -142,9 +143,10 @@ defmodule Hammer.ETS.FixWindow do @doc """ Returns the count of requests for a given key within the last 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 diff --git a/test/hammer/ets/fix_window_test.exs b/test/hammer/ets/fix_window_test.exs index 7675248..26fd0fe 100644 --- a/test/hammer/ets/fix_window_test.exs +++ b/test/hammer/ets/fix_window_test.exs @@ -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)