Packages
electric
1.6.8
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0
1.5.1
1.5.0
1.4.16
1.4.16-beta-1
1.4.15
1.4.14
1.4.13
1.4.12
1.4.11
1.4.10
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.4
1.3.3
1.3.2
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.14
1.1.13
1.1.12
1.1.11
1.1.10
1.1.9
1.1.8
1.1.7
1.1.6
retired
1.1.5
retired
1.1.4
retired
1.1.3
retired
1.1.2
1.1.1
1.1.0
1.0.24
1.0.23
1.0.22
1.0.21
1.0.20
1.0.19
1.0.18
1.0.17
1.0.15
1.0.13
1.0.12
1.0.11
1.0.10
1.0.9
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
1.0.0-beta.23
1.0.0-beta.22
1.0.0-beta.20
1.0.0-beta.19
1.0.0-beta.18
1.0.0-beta.17
1.0.0-beta.16
1.0.0-beta.15
1.0.0-beta.14
1.0.0-beta.13
1.0.0-beta.12
1.0.0-beta.11
1.0.0-beta.10
1.0.0-beta.9
1.0.0-beta.8
1.0.0-beta.7
1.0.0-beta.6
1.0.0-beta.5
1.0.0-beta.4
1.0.0-beta.3
1.0.0-beta.2
1.0.0-beta.1
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.1
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.5.2
0.4.4
Postgres sync engine. Sync little subsets of your Postgres data into local apps and services.
Current section
Files
Jump to
Current section
Files
lib/electric/admission_control.ex
defmodule Electric.AdmissionControl do
@moduledoc """
Simple admission control using ETS-based counters to limit concurrent requests per stack.
This module prevents server overload by:
- Limiting the number of concurrent requests per type of request (initial or existing) within a stack
- Failing fast with 503 + Retry-After when at capacity
- Using cheap ETS operations for minimal overhead
## Usage
# Try to acquire a permit for a stack
case Electric.AdmissionControl.try_acquire(stack_id, :initial, max_concurrent: 1000) do
:ok ->
# Request is allowed, process it
# Don't forget to call release/1 when done!
{:error, :overloaded} ->
# Too many concurrent requests, return 503
end
# Always release the permit when done
Electric.AdmissionControl.release(stack_id, :initial)
## Configuration
The max_concurrent limit can be configured in your config files:
config :electric, :max_concurrent_requests, %{initial: 300, existing: 10_000}
"""
use GenServer
require Logger
@table_name :electric_admission_control
@doc """
Start the admission control GenServer.
## Options
* `:table_name` - Custom ETS table name (default: `:electric_admission_control`)
* `:name` - GenServer name (default: `__MODULE__`)
"""
def start_link(opts) do
{name, opts} = Keyword.pop(opts, :name, __MODULE__)
GenServer.start_link(__MODULE__, opts, name: name)
end
@allowed_kinds ~w|initial existing|a
for {kind, pos} <- Enum.with_index(@allowed_kinds, 2) do
defp tuple_pos(unquote(kind)), do: unquote(pos)
end
@doc """
Try to acquire a permit for the given stack_id.
Returns `:ok` if permit granted, `{:error, :overloaded}` if at capacity.
## Options
* `:max_concurrent` - Maximum concurrent requests allowed (default: 1000)
* `:table_name` - ETS table name (default: `:electric_admission_control`)
## Examples
iex> Electric.AdmissionControl.try_acquire("stack-123", :initial, max_concurrent: 1000)
:ok
iex> Electric.AdmissionControl.try_acquire("stack-123", :initial, max_concurrent: 1)
{:error, :overloaded}
"""
def try_acquire(stack_id, kind, opts \\ []) when kind in @allowed_kinds do
table_name = Keyword.get(opts, :table_name, @table_name)
max_concurrent =
Keyword.get_lazy(opts, :max_concurrent, fn ->
Electric.Config.get_env(:max_concurrent_requests)
|> Map.fetch!(kind)
end)
current = incr(table_name, stack_id, kind)
if current > max_concurrent do
# At or over capacity, decrement back and reject
decr(table_name, stack_id, kind)
# Emit telemetry event
:telemetry.execute(
[:electric, :admission_control, :reject],
%{count: 1, limit: max_concurrent},
%{
stack_id: stack_id,
reason: :overloaded,
kind: kind,
current: current
}
)
{:error, :overloaded}
else
# Successfully acquired permit
# Emit telemetry for current concurrency level
:telemetry.execute(
[:electric, :admission_control, :acquire],
%{count: 1, current: current, limit: max_concurrent},
%{stack_id: stack_id, kind: kind}
)
:ok
end
end
@doc """
Release a permit for the given stack_id.
Always call this after processing a request, even if it errors.
Consider using a try/after or Plug's `register_before_send/2` callback.
## Options
* `:table_name` - ETS table name (default: `:electric_admission_control`)
## Examples
iex> Electric.AdmissionControl.release("stack-123", :initial)
:ok
"""
def release(stack_id, kind, opts \\ []) when kind in @allowed_kinds do
table_name = Keyword.get(opts, :table_name, @table_name)
decr(table_name, stack_id, kind)
:ok
end
@doc """
Move an in-flight permit from `from_kind` to `to_kind` for a stack.
The caller must already hold a `from_kind` permit (acquired via
`try_acquire/3`). Calling `try_swap/4` without an outstanding
`from_kind` permit will silently grant a phantom `to_kind` permit.
## Options
* `:max_concurrent` — required. Cap for `to_kind`.
* `:table_name` — ETS table (default: `:electric_admission_control`).
"""
@spec try_swap(String.t(), atom(), atom(), keyword()) :: :ok | {:error, :overloaded}
def try_swap(stack_id, from_kind, to_kind, opts)
when from_kind in @allowed_kinds and to_kind in @allowed_kinds do
table_name = Keyword.get(opts, :table_name, @table_name)
cap = Keyword.fetch!(opts, :max_concurrent)
# Plain increment, no threshold clamp. Returns the post-increment value.
new_to = incr(table_name, stack_id, to_kind)
if new_to > cap do
# Rollback the claimed permit since the bucket is already at capacity.
decr(table_name, stack_id, to_kind)
:telemetry.execute(
[:electric, :admission_control, :swap_rejected],
%{count: 1, current: new_to, limit: cap},
%{stack_id: stack_id, from: from_kind, to: to_kind}
)
{:error, :overloaded}
else
# Success: drop the from_kind permit. Clamp at 0 to be defensive
# against callers who lied about holding a from_kind permit.
decr(table_name, stack_id, from_kind)
:telemetry.execute(
[:electric, :admission_control, :swap],
%{count: 1, current: new_to, limit: cap},
%{stack_id: stack_id, from: from_kind, to: to_kind}
)
:ok
end
end
@doc """
Get the current number of in-flight requests for a stack.
Returns a map with `:initial` and `:existing` counts.
Useful for monitoring and debugging.
## Options
* `:table_name` - ETS table name (default: `:electric_admission_control`)
## Examples
iex> Electric.AdmissionControl.get_current("stack-123")
%{initial: 5, existing: 10}
"""
def get_current(stack_id, opts \\ []) do
table_name = Keyword.get(opts, :table_name, @table_name)
case :ets.lookup(table_name, stack_id) do
[{^stack_id, initial, existing}] -> %{initial: initial, existing: existing}
[] -> %{initial: 0, existing: 0}
end
end
@impl true
def init(opts) do
table_name = Keyword.get(opts, :table_name, @table_name)
:ets.new(table_name, [
:named_table,
:public,
:set,
write_concurrency: true,
read_concurrency: true
])
Logger.notice("Admission control initialized with table: #{table_name}")
{:ok, %{table_name: table_name}}
end
defp incr(table_name, stack_id, kind) do
:ets.update_counter(table_name, stack_id, {tuple_pos(kind), 1}, {stack_id, 0, 0})
end
defp decr(table_name, stack_id, kind) do
:ets.update_counter(table_name, stack_id, {tuple_pos(kind), -1, 0, 0}, {stack_id, 0, 0})
end
end