From e76d3b81953c53761f636d0e5790f7d73653cc33 Mon Sep 17 00:00:00 2001 From: epinault Date: Mon, 30 Mar 2026 08:59:27 -0700 Subject: [PATCH] Add expires_at/2 API for fix_window algorithm Adds an expires_at/2 function that returns the expiration timestamp (ms) of the current window for a given key. Available on both ETS and Atomic backends for the fix_window algorithm only. Returns 0 if no active window. Closes #138 --- lib/hammer.ex | 10 +++++++++- lib/hammer/atomic.ex | 6 ++++++ lib/hammer/atomic/fix_window.ex | 16 ++++++++++++++++ lib/hammer/ets.ex | 6 ++++++ lib/hammer/ets/fix_window.ex | 16 ++++++++++++++++ test/hammer/atomic/fix_window_test.exs | 19 +++++++++++++++++++ test/hammer/ets/fix_window_test.exs | 19 +++++++++++++++++++ 7 files changed, 91 insertions(+), 1 deletion(-) diff --git a/lib/hammer.ex b/lib/hammer.ex index 492d44b..b7fb8b7 100644 --- a/lib/hammer.ex +++ b/lib/hammer.ex @@ -68,7 +68,15 @@ defmodule Hammer do """ @callback get(key, scale) :: count - @optional_callbacks hit: 4, inc: 2, inc: 3, set: 3, get: 2 + @doc """ + Optional callback for getting the expiration time of the current window for a key. + + Returns the expiration timestamp in milliseconds, or `0` if the key has no active window. + Only available for the `:fix_window` algorithm. + """ + @callback expires_at(key, scale) :: non_neg_integer() + + @optional_callbacks hit: 4, inc: 2, inc: 3, set: 3, get: 2, expires_at: 2 @doc """ Use the Hammer library in a module to create a rate limiter. diff --git a/lib/hammer/atomic.ex b/lib/hammer/atomic.ex index b24d367..132afb3 100644 --- a/lib/hammer/atomic.ex +++ b/lib/hammer/atomic.ex @@ -150,6 +150,12 @@ defmodule Hammer.Atomic do @algorithm.get(@table, key) end end + + if function_exported?(@algorithm, :expires_at, 3) do + def expires_at(key, scale) do + @algorithm.expires_at(@table, key, scale) + end + end end end diff --git a/lib/hammer/atomic/fix_window.ex b/lib/hammer/atomic/fix_window.ex index 41838d3..827a43c 100644 --- a/lib/hammer/atomic/fix_window.ex +++ b/lib/hammer/atomic/fix_window.ex @@ -186,6 +186,22 @@ defmodule Hammer.Atomic.FixWindow do end end + @doc """ + Returns the expiration time (in milliseconds) of the current window for a given key. + + Returns `0` if the key has no active window. + """ + @spec expires_at(table :: atom(), key :: term(), scale :: pos_integer()) :: non_neg_integer() + def expires_at(table, key, scale) do + window = div(Atomic.now(), scale) + full_key = {key, window} + + case :ets.lookup(table, full_key) do + [{_, atomic}] -> :atomics.get(atomic, 2) + [] -> 0 + end + end + @doc false @spec normalize_entry(key :: term(), atomic :: reference()) :: map() def normalize_entry(key, atomic) do diff --git a/lib/hammer/ets.ex b/lib/hammer/ets.ex index 189f92d..914536a 100644 --- a/lib/hammer/ets.ex +++ b/lib/hammer/ets.ex @@ -149,6 +149,12 @@ defmodule Hammer.ETS do @algorithm.get(@table, key) end end + + if function_exported?(@algorithm, :expires_at, 3) do + def expires_at(key, scale) do + @algorithm.expires_at(@table, key, scale) + end + end end end diff --git a/lib/hammer/ets/fix_window.ex b/lib/hammer/ets/fix_window.ex index 8331132..5d7147c 100644 --- a/lib/hammer/ets/fix_window.ex +++ b/lib/hammer/ets/fix_window.ex @@ -164,6 +164,22 @@ defmodule Hammer.ETS.FixWindow do :ets.select_delete(table, match_spec) end + @doc """ + Returns the expiration time (in milliseconds) of the current window for a given key. + + Returns `0` if the key has no active window. + """ + @spec expires_at(table :: atom(), key :: term(), scale :: pos_integer()) :: non_neg_integer() + def expires_at(table, key, scale) do + window = div(ETS.now(), scale) + full_key = {key, window} + + case :ets.lookup(table, full_key) do + [{_full_key, _count, expires_at}] -> expires_at + [] -> 0 + end + end + @doc false @spec select_expired(config :: ETS.config()) :: list() def select_expired(config) do diff --git a/test/hammer/atomic/fix_window_test.exs b/test/hammer/atomic/fix_window_test.exs index f5aa08d..39189a3 100644 --- a/test/hammer/atomic/fix_window_test.exs +++ b/test/hammer/atomic/fix_window_test.exs @@ -128,6 +128,25 @@ defmodule Hammer.Atomic.FixWindowTest do end end + describe "expires_at" do + test "returns 0 for unknown key", %{table: table} do + assert FixWindow.expires_at(table, "unknown", :timer.seconds(10)) == 0 + end + + test "returns the expiration timestamp after a hit", %{table: table} do + key = "key" + scale = :timer.seconds(10) + + assert {:allow, 1} = FixWindow.hit(table, key, scale, 10, 1) + + expires_at = FixWindow.expires_at(table, key, scale) + now = System.system_time(:millisecond) + + assert expires_at > now + assert expires_at <= now + scale + end + end + describe "get/set" do test "get returns the count set for the given key and scale", %{table: table} do key = "key" diff --git a/test/hammer/ets/fix_window_test.exs b/test/hammer/ets/fix_window_test.exs index 360f6da..7675248 100644 --- a/test/hammer/ets/fix_window_test.exs +++ b/test/hammer/ets/fix_window_test.exs @@ -100,6 +100,25 @@ defmodule Hammer.ETS.FixWindowTest do end end + describe "expires_at" do + test "returns 0 for unknown key", %{table: table} do + assert FixWindow.expires_at(table, "unknown", :timer.seconds(10)) == 0 + end + + test "returns the expiration timestamp after a hit", %{table: table} do + key = "key" + scale = :timer.seconds(10) + + assert {:allow, 1} = FixWindow.hit(table, key, scale, 10, 1) + + expires_at = FixWindow.expires_at(table, key, scale) + now = System.system_time(:millisecond) + + assert expires_at > now + assert expires_at <= now + scale + end + end + describe "get/set" do test "get returns the count set for the given key and scale", %{table: table} do key = "key"