Packages
electric
1.2.0
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/async_deleter.ex
defmodule Electric.AsyncDeleter do
@moduledoc """
A GenServer that batches file/directory deletions by first moving them into a
per-stack trash directory and then, after a configurable interval, removing
the trash directory contents in one `rm -rf` operation.
This reduces filesystem churn when many deletes happen in quick succession
(e.g. cache eviction) and avoids blocking callers: `delete/1` returns after a
quick `File.rename/2` into the trash directory.
Configuration:
* `:cleanup_interval_ms` - interval in milliseconds after the
first queued delete before the batch is removed. Defaults to 10000 ms.
"""
use GenServer
require Logger
defstruct [
:stack_id,
:interval_ms,
timer_ref: nil,
pending: []
]
@trash_dir_base ".electric_trash"
@default_cleanup_interval_ms 10_000
def name(stack_id) when is_binary(stack_id),
do: Electric.ProcessRegistry.name(stack_id, __MODULE__)
def name(opts), do: name(opts[:stack_id])
def start_link(opts) do
stack_id = Keyword.fetch!(opts, :stack_id)
{storage_dir, opts} = Keyword.pop(opts, :storage_dir)
Electric.StackConfig.put(stack_id, {__MODULE__, :trash_dir}, trash_dir(storage_dir, stack_id))
GenServer.start_link(__MODULE__, opts, name: name(stack_id))
end
@doc """
Deletes a file or directory using rm -rf.
Returns {:ok, output} on success or {:error, reason} on failure.
"""
def delete(path, opts) when is_binary(path) do
stack_id =
opts[:stack_id] ||
raise ArgumentError, message: "Missing required :stack_id in opts: #{inspect(opts)}"
trash_dir = trash_dir!(stack_id)
case do_rename(path, trash_dir) do
{:ok, _dest} ->
server = opts[:server] || name(stack_id)
GenServer.cast(server, {:schedule_cleanup, path})
:ok
{:error, :enoent} ->
Logger.debug("AsyncDeleter: path already gone #{path}")
:ok
{:error, reason} ->
Logger.warning("AsyncDeleter: rename failed for #{path}: #{inspect(reason)}")
{:error, reason}
end
end
@impl true
def init(opts) do
stack_id = Keyword.fetch!(opts, :stack_id)
Process.set_label({:async_deleter, stack_id})
Electric.Telemetry.Sentry.set_tags_context(stack_id: stack_id)
state = %__MODULE__{
stack_id: stack_id,
interval_ms: Keyword.get(opts, :cleanup_interval_ms, @default_cleanup_interval_ms),
timer_ref: nil,
pending: []
}
{:ok, state, {:continue, :initial_cleanup}}
end
@impl true
def handle_continue(:initial_cleanup, state) do
{:noreply, do_cleanup(state)}
end
# schedule timer if not already running
def handle_continue(:schedule_cleanup, %{timer_ref: nil} = state) do
Logger.debug("AsyncDeleter: scheduling cleanup in #{state.interval_ms}ms")
{:noreply,
%{state | timer_ref: Process.send_after(self(), :perform_delete, state.interval_ms)}}
end
def handle_continue(:schedule_cleanup, state), do: {:noreply, state}
@impl true
def handle_cast({:schedule_cleanup, path}, state) do
{:noreply, %{state | pending: [path | state.pending]}, {:continue, :schedule_cleanup}}
end
defp unique_destination(trash_dir, base) do
attempt = Path.join(trash_dir, base <> "_" <> random_suffix())
if File.exists?(attempt) do
unique_destination(trash_dir, base)
else
attempt
end
end
defp random_suffix, do: System.unique_integer([:positive]) |> to_string()
@impl true
def handle_info(:perform_delete, state) do
{:noreply, do_cleanup(state)}
end
def trash_dir!(stack_id) do
Electric.StackConfig.lookup(stack_id, {__MODULE__, :trash_dir})
rescue
ArgumentError ->
raise RuntimeError,
message: "#{inspect(__MODULE__)} config is missing for stack #{stack_id}"
end
def trash_dir(storage_dir, stack_id), do: Path.join([storage_dir, @trash_dir_base, stack_id])
defp do_rename(path, trash_dir) do
dest = unique_destination(trash_dir, Path.basename(path))
with :ok <- File.mkdir_p(trash_dir),
:ok <- File.rename(path, dest) do
{:ok, dest}
end
end
defp do_cleanup(state) do
# Remove the entire trash dir contents in one go
if state.pending != [] do
start_time = System.monotonic_time(:millisecond)
try do
clean_dir!(trash_dir!(state.stack_id))
rescue
e -> Logger.warning("AsyncDeleter: rm_rf failed: #{inspect(e)}")
end
duration = System.monotonic_time(:millisecond) - start_time
Logger.debug(
"AsyncDeleter: deleted #{length(state.pending)} paths " <>
"for stack #{state.stack_id} in #{duration}ms"
)
end
%{state | pending: [], timer_ref: nil}
end
def clean_dir!(path) do
path
|> File.ls!()
|> Enum.each(fn entry ->
path
|> Path.join(entry)
|> unsafe_cleanup_with_retries!()
end)
end
defp unsafe_cleanup_with_retries!(directory, attempts_left \\ 5) do
with {:ok, _} <- File.rm_rf(directory) do
:ok
else
# There is a very unlikely but observed scenario where the rm_rf call
# tries to delete a directory after having deleted all its files, but
# due to some FS race the deletion fails with EEXIST. Very hard to test
# and prevent so we mitigate it with arbitrary retries.
{:error, :eexist, _} when attempts_left > 0 ->
unsafe_cleanup_with_retries!(directory, attempts_left - 1)
{:error, reason, path} ->
raise File.Error,
reason: reason,
path: path,
action: "remove files and directories recursively from"
end
end
end