Packages
electric
1.7.5
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/shape_cache/shape_status/shape_db/write_buffer.ex
defmodule Electric.ShapeCache.ShapeStatus.ShapeDb.WriteBuffer do
@moduledoc """
Buffers SQLite metadata writes using ETS for immediate return to callers,
then batches and flushes to SQLite in the background.
This prevents timeout cascades when many concurrent clients create shapes
after a redeploy.
## Architecture
Two ETS tables are used:
1. **Operations table** (ordered_set) - queue of operations to flush to SQLite
2. **Shapes table** (set) - buffered shapes, comparable index, and tombstones with namespaced keys
## How it works
1. When a shape is added, insert into shapes table and queue :add operation
2. When a shape is removed, insert tombstone and queue :remove operation
3. GenServer polls every 50ms and flushes pending operations to SQLite
4. After successful flush, clean up entries from shapes table
## Crash recovery
If the system crashes, all in-flight writes in the operations table will be
lost. On reboot clients of the in-flight shapes will receive `must-refetch`
responses and the shapes will be re-inserted into the buffer.
This will leave orphaned shape data in the storage implementation, as we are
losing all references to the handle. We will need some background
reconciliation process that culls orphaned storage data.
## Operations table format
`{{monotonic_time, unique_int}, operation, flushing}`
## Shapes table key formats
- `{:pending_count, integer}` - count of shapes pending addition
- `{{:shape, handle}, shape, comparable}` - shape data
- `{{:comparable, comparable}, handle}` - reverse index for O(1) lookup
- `{{:tombstone, handle}, timestamp}` - handles marked for deletion
"""
use GenServer
require Logger
alias Electric.ShapeCache.ShapeStatus.ShapeDb.Connection
alias Electric.ShapeCache.ShapeStatus.ShapeDb.Query
alias Electric.Telemetry.OpenTelemetry
import Electric, only: [is_stack_id: 1]
@poll_interval 100
# keep this low-ish so that this process yields the write connection
# to handle_for_shape_critical/2 reasonably often.
@max_drain_per_cycle 100
def operations_table_name(stack_id), do: :"write_buffer_ops:#{stack_id}"
def shapes_table_name(stack_id), do: :"write_buffer_shapes:#{stack_id}"
def name(stack_id), do: Electric.ProcessRegistry.name(stack_id, __MODULE__)
@doc "Returns a monotonic timestamp for ordering writes"
def timestamp, do: System.monotonic_time()
@doc "Returns a unique, ordered key for operations table entries"
def op_key, do: {System.monotonic_time(), System.unique_integer([:positive, :monotonic])}
def start_link(args) do
stack_id = Keyword.fetch!(args, :stack_id)
GenServer.start_link(__MODULE__, args, name: name(stack_id))
end
@doc """
Synchronously flush all pending writes. Useful for testing and graceful shutdown.
"""
def flush_sync(stack_id, timeout \\ 5000) when is_stack_id(stack_id) do
GenServer.call(name(stack_id), :flush_sync, timeout)
end
@doc """
Disable flushing of buffered actions to db. Useful for testing
"""
def pause_flush(stack_id) when is_stack_id(stack_id) do
GenServer.call(name(stack_id), {:pause, true})
end
@doc """
Re-enable flushing of buffered actions to db. Useful for testing
"""
def resume_flush(stack_id) when is_stack_id(stack_id) do
GenServer.call(name(stack_id), {:pause, false})
end
# Lookup functions
@doc "Look up a shape by its handle. Returns {:ok, shape} or :not_found."
def lookup_shape(stack_id, handle) do
shapes_table = shapes_table_name(stack_id)
case :ets.lookup(shapes_table, {:shape, handle}) do
[{{:shape, ^handle}, shape, _comparable}] ->
if :ets.member(shapes_table, {:tombstone, handle}) do
:not_found
else
{:ok, shape}
end
[] ->
:not_found
end
end
@doc "Look up a handle by comparable shape binary. Returns {:ok, handle} or :not_found."
def lookup_handle(stack_id, comparable_shape) do
shapes_table = shapes_table_name(stack_id)
case :ets.lookup(shapes_table, {:comparable, comparable_shape}) do
[{{:comparable, ^comparable_shape}, handle}] ->
if :ets.member(shapes_table, {:tombstone, handle}), do: :not_found, else: {:ok, handle}
[] ->
:not_found
end
end
@doc "Check if a handle is in the tombstones (marked for deletion)"
def is_tombstoned?(stack_id, handle) do
:ets.member(shapes_table_name(stack_id), {:tombstone, handle})
end
@doc """
Check if a handle exists in the buffer.
Returns:
- `false` if tombstoned (being deleted)
- `true` if in shapes table (buffered, not yet in SQLite)
- `:unknown` if not in buffer (may or may not exist in SQLite)
Note: Checks tombstone last since it's the authoritative "delete in progress"
signal, avoiding a race where tombstone is added between checks.
"""
def has_handle?(stack_id, handle) do
shapes_table = shapes_table_name(stack_id)
has_shape = :ets.member(shapes_table, {:shape, handle})
is_tombstoned = :ets.member(shapes_table, {:tombstone, handle})
cond do
is_tombstoned -> false
has_shape -> true
true -> :unknown
end
end
@doc "Returns the number of pending operations in the buffer"
def pending_operations_count(stack_id) do
:ets.info(operations_table_name(stack_id), :size)
rescue
ArgumentError -> 0
end
@doc "Gives the change to the total count of shapes in the database once all buffered writes are applied"
def pending_count_diff(stack_id) do
:ets.lookup_element(shapes_table_name(stack_id), :count_add, 2) -
:ets.lookup_element(shapes_table_name(stack_id), :count_remove, 2)
end
@doc "Returns all buffered shapes as a list of {handle, shape} tuples, excluding tombstoned handles, sorted by handle"
def list_buffered_shapes(stack_id) do
tombstones = tombstoned_handles(stack_id)
shapes_table_name(stack_id)
|> :ets.match({{:shape, :"$1"}, :"$2", :_})
|> Enum.reject(fn [handle, _] -> MapSet.member?(tombstones, handle) end)
|> Enum.map(fn [handle, shape] -> {handle, shape} end)
|> Enum.sort_by(fn {handle, _} -> handle end)
end
@doc "Returns handles from the buffer that match any of the given relations (by OID)"
def handles_for_relations(stack_id, relations) do
if relations == [] do
[]
else
tombstones = tombstoned_handles(stack_id)
oids_set = relations |> Enum.map(fn {oid, _relation} -> oid end) |> MapSet.new()
operations_table_name(stack_id)
|> :ets.match({:_, {:add, :"$1", :_, :_, :_, :"$2"}, :_})
|> Enum.filter(fn [handle, shape_relations] ->
not MapSet.member?(tombstones, handle) and
Enum.any?(shape_relations, fn {oid, _} -> MapSet.member?(oids_set, oid) end)
end)
|> Enum.map(fn [handle, _] -> handle end)
end
end
@doc "Returns all tombstoned handles as a MapSet"
def tombstoned_handles(stack_id) do
shapes_table_name(stack_id)
|> :ets.match({{:tombstone, :"$1"}, :_})
|> MapSet.new(fn [h] -> h end)
end
# Write functions
@doc "Add a shape to the buffer"
def add_shape(stack_id, handle, shape, comparable, hash, relations) do
shapes_table = shapes_table_name(stack_id)
ops_table = operations_table_name(stack_id)
ts = op_key()
if :ets.insert_new(shapes_table, [
{{:shape, handle}, shape, comparable},
{{:comparable, comparable}, handle}
]) do
:ets.update_counter(shapes_table, :count_add, 1)
# Queue operation for SQLite (contains full data including hash and relations)
true =
:ets.insert(
ops_table,
{ts, {:add, handle, shape, comparable, hash, relations}, false}
)
:ok
else
{:error, "duplicate shape #{handle} #{inspect(shape)}"}
end
end
@doc "Mark a shape for removal"
def remove_shape(stack_id, handle) do
shapes_table = shapes_table_name(stack_id)
ts = op_key()
# insert_new is atomic - returns false if already tombstoned, preventing double-remove
case :ets.insert_new(shapes_table, {{:tombstone, handle}, ts}) do
true ->
case :ets.lookup(shapes_table, {:shape, handle}) do
[{{:shape, ^handle}, _shape, comparable}] ->
:ets.delete(shapes_table, {:comparable, comparable})
[] ->
:ok
end
:ets.delete(shapes_table, {:shape, handle})
:ets.update_counter(shapes_table, :count_remove, 1)
ops_table = operations_table_name(stack_id)
true = :ets.insert(ops_table, {ts, {:remove, handle}, false})
:ok
false ->
:ok
end
end
@doc "Queue a snapshot_complete operation"
def queue_snapshot_complete(stack_id, handle) do
ops_table = operations_table_name(stack_id)
ts = op_key()
true = :ets.insert(ops_table, {ts, {:snapshot_complete, handle}, false})
:ok
end
@doc "Clear all data from all tables"
def clear(stack_id) do
:ets.delete_all_objects(shapes_table_name(stack_id))
:ets.delete_all_objects(operations_table_name(stack_id))
initialize_counts(shapes_table_name(stack_id))
:ok
end
@impl GenServer
def init(opts) do
# Trap exits to enable the terminate/2 callback, giving the buffer time
# to flush to the db before terminating
Process.flag(:trap_exit, true)
stack_id = Keyword.fetch!(opts, :stack_id)
manual_flush_only = Keyword.get(opts, :manual_flush_only, false)
Process.set_label({:shape_db_write_buffer, stack_id})
Logger.metadata(stack_id: stack_id)
Electric.Telemetry.Sentry.set_tags_context(stack_id: stack_id)
ops_table = operations_table_name(stack_id)
shapes_table = shapes_table_name(stack_id)
:ets.new(ops_table, [
:named_table,
:public,
:ordered_set,
write_concurrency: :auto,
read_concurrency: true
])
:ets.new(shapes_table, [
:named_table,
:public,
:set,
write_concurrency: :auto,
read_concurrency: true
])
initialize_counts(shapes_table)
{:ok,
schedule_poll(%{
stack_id: stack_id,
ops_table: ops_table,
shapes_table: shapes_table,
manual_flush_only: manual_flush_only,
paused: false
})}
end
defp initialize_counts(shapes_table) do
:ets.insert(shapes_table, [{:count_add, 0}, {:count_remove, 0}])
end
@impl GenServer
def handle_info(:poll, %{manual_flush_only: false} = state) do
flush_until_empty(state)
{:noreply, schedule_poll(state), :hibernate}
end
def handle_info({:EXIT, _pid, reason}, state) do
# We're trapping exits to enable the terminate/2 callback, not to keep
# this process alive
{:stop, reason, state}
end
def handle_info(msg, state) do
Logger.warning("Received unexpected message #{inspect(msg)}")
{:noreply, state}
end
@impl GenServer
def handle_call(:flush_sync, _from, state) do
{:reply, flush_until_empty(%{state | paused: false}), state}
end
def handle_call({:pause, paused?}, _from, state) do
{:reply, :ok, %{state | paused: paused?}}
end
@impl GenServer
def terminate(_reason, state) do
flush_until_empty(state)
end
defp schedule_poll(%{manual_flush_only: true} = state) do
state
end
defp schedule_poll(state) do
Process.send_after(self(), :poll, @poll_interval)
state
end
defp flush_until_empty(%{paused: true} = _state) do
:ok
end
defp flush_until_empty(state) do
state
|> mark_and_collect_entries(@max_drain_per_cycle)
|> flush_entries(state)
end
defp flush_entries([], _state) do
:ok
end
defp flush_entries(
entries,
%{ops_table: ops_table, shapes_table: shapes_table, stack_id: stack_id} = state
) do
OpenTelemetry.with_span(
"shape_db.write_buffer.flush",
[entry_count: length(entries)],
stack_id,
fn ->
case do_batch_write(stack_id, entries) do
:ok ->
Enum.each(entries, fn {ts, op} ->
case op do
{:add, handle, _shape, comparable, _hash, _relations} ->
:ets.delete(shapes_table, {:shape, handle})
:ets.delete(shapes_table, {:comparable, comparable})
:ets.update_counter(shapes_table, :count_add, {2, -1, 0, 0})
{:add, handle}
{:remove, handle} ->
:ets.delete(shapes_table, {:tombstone, handle})
:ets.update_counter(shapes_table, :count_remove, {2, -1, 0, 0})
op
_ ->
op
end
|> tap(&Logger.debug(fn -> ["ShapeDb: committed ", inspect(&1)] end))
:ets.delete(ops_table, ts)
end)
flush_until_empty(state)
{:error, _reason} = error ->
# Reset flushing flag so entries can be retried on next poll
Enum.each(entries, fn {ts, _op} ->
:ets.update_element(ops_table, ts, {3, false})
end)
error
end
end
)
end
defp mark_and_collect_entries(%{ops_table: ops_table}, limit) do
# Select entries with flushing=false
match_spec = [{{:"$1", :"$2", false}, [], [{{:"$1", :"$2"}}]}]
case :ets.select(ops_table, match_spec, limit) do
{results, _continuation} ->
Enum.map(results, fn {ts, _op} = entry ->
:ets.update_element(ops_table, ts, {3, true})
entry
end)
:"$end_of_table" ->
[]
end
end
defp do_batch_write(stack_id, entries) do
Connection.checkout_write!(stack_id, :batch_write, fn conn ->
Enum.each(entries, fn
{_ts, {:add, handle, shape, comparable, hash, relations}} ->
:ok = Query.add_shape(conn, handle, shape, comparable, hash, relations)
{_ts, {:remove, handle}} ->
case Query.remove_shape(conn, handle) do
{:error, {:enoshape, ^handle}} ->
# tried to delete a shape that doesn't exist, in which case the
# failure is ok, it's already deleted
Logger.warning("Attempt to delete non-existent shape #{inspect(handle)}")
:ok ->
:ok
error ->
raise "Failed to remove shape: #{inspect(error)}"
end
{_ts, {:snapshot_complete, handle}} ->
with :error <- Query.mark_snapshot_complete(conn, handle) do
# `Query.mark_snapshot_complete` only returns `:error` if the query
# modified 0 rows, i.e. the shape does not exist. Rather than crash
# just warn as we can continue in this scenario.
Logger.warning("Unable to mark snapshot complete: #{handle} does not exist")
end
end)
:ok
end)
rescue
e ->
Logger.error(
"WriteBuffer batch write failed: #{Exception.format(:error, e, __STACKTRACE__)}"
)
{:error, Exception.message(e)}
end
end