Packages
Mnesia backend for Hammer rate-limiter
Current section
Files
Jump to
Current section
Files
lib/hammer/mnesia/fix_window.ex
defmodule Hammer.Mnesia.FixWindow do
@moduledoc """
This module implements the Fix Window algorithm for the [Mnesia](https://hex.pm/packages/mnesia) backend.
The fixed window algorithm works by dividing time into fixed intervals or "windows"
of a specified duration (scale). Each window tracks request counts independently.
For example, with a 60 second window:
- Window 1: 0-60 seconds
- Window 2: 60-120 seconds
- And so on...
## The algorithm:
1. When a request comes in, we:
- Calculate which window it belongs to based on current time
- Increment the counter for that window
- Store expiration time as end of window
2. To check if rate limit is exceeded:
- If count <= limit: allow request
- If count > limit: deny and return time until window expires
3. Old windows are automatically cleaned up after expiration
This provides simple rate limiting but has edge cases where a burst of requests
spanning a window boundary could allow up to 2x the limit in a short period.
For more precise limiting, consider using the sliding window algorithm instead.
The fixed window algorithm is a good choice when:
- You need simple, predictable rate limiting with clear time boundaries
- The exact precision of the rate limit is not critical
- You want efficient implementation with minimal storage overhead
- Your use case can tolerate potential bursts at window boundaries
## Common use cases include:
- Basic API rate limiting where occasional bursts are acceptable
- Protecting backend services from excessive load
- Implementing fair usage policies
- Scenarios where clear time-based quotas are desired (e.g. "100 requests per minute")
The main tradeoff is that requests near window boundaries can allow up to 2x the
intended limit in a short period. For example with a limit of 100 per minute:
- 100 requests at 11:59:59
- Another 100 requests at 12:00:01
This results in 200 requests in 2 seconds, while still being within limits.
If this behavior is problematic, consider using the sliding window algorithm instead.
The fixed window algorithm supports the following options:
- `:clean_period` - How often to run the cleanup process (in milliseconds)
Defaults to 1 minute. The cleanup process removes expired window entries.
## Example
### Example configuration:
MyApp.RateLimit.start_link(
clean_period: :timer.minutes(5),
)
This would run cleanup every 5 minutes and clean up old windows.
### Example usage:
defmodule MyApp.RateLimit do
use Hammer, backend: Hammer.Mnesia, algorithm: :fix_window
end
MyApp.RateLimit.start_link(clean_period: :timer.minutes(1))
# Allow 10 requests per second
MyApp.RateLimit.hit("user_123", 1000, 10)
"""
alias Hammer.Mnesia
@spec mnesia_opts() :: list()
def mnesia_opts do
[
type: :set,
attributes: [:key, :count]
]
end
@doc false
@spec hit(
table :: atom(),
key :: term(),
scale :: non_neg_integer(),
limit :: non_neg_integer(),
increment :: non_neg_integer()
) ::
{:allow, non_neg_integer()} | {:deny, non_neg_integer()}
def hit(table, key, scale, limit, increment) do
now = Mnesia.now()
full_key = full_key(key, scale, now)
count = :mnesia.dirty_update_counter(table, full_key, increment)
if count <= limit do
{:allow, count}
else
{_, _, expires_at} = full_key
retry_after = max(expires_at - now, 0)
{:deny, retry_after}
end
end
@doc false
@spec inc(
table :: atom(),
key :: term(),
scale :: non_neg_integer(),
increment :: non_neg_integer()
) :: non_neg_integer()
def inc(table, key, scale, increment) do
:mnesia.dirty_update_counter(table, full_key(key, scale), increment)
end
@doc false
@spec set(
table :: atom(),
key :: term(),
scale :: non_neg_integer(),
count :: non_neg_integer()
) :: non_neg_integer()
def set(table, key, scale, count) do
full_key = full_key(key, scale)
current_count = :mnesia.dirty_update_counter(table, full_key, 0)
:mnesia.dirty_update_counter(table, full_key, count - current_count)
end
@doc false
@spec get(table :: atom(), key :: term(), scale :: non_neg_integer()) :: non_neg_integer()
def get(table, key, scale) do
:mnesia.dirty_update_counter(table, full_key(key, scale), 0)
end
@compile inline: [full_key: 2]
def full_key(key, scale) do
full_key(key, scale, Mnesia.now())
end
@compile inline: [full_key: 3]
def full_key(key, scale, now) do
window = div(now, scale)
expires_at = (window + 1) * scale
{key, window, expires_at}
end
@spec clean(config :: keyword()) :: :ok
def clean(config) do
# generated with `:ets.fun2ms fn {_, {k, w, e}, _} when e < 123 -> {k, w, e} end`
ms = [
{{:_, {:"$1", :"$2", :"$3"}, :_}, [{:<, :"$3", {:const, Mnesia.now()}}],
[{{:"$1", :"$2", :"$3"}}]}
]
expired_keys = :mnesia.dirty_select(config.table, ms)
Enum.each(expired_keys, fn key -> :mnesia.dirty_delete(config.table, key) end)
end
end