Current section

Files

Jump to
electric lib electric async_deleter.ex
Raw

lib/electric/async_deleter.ex

defmodule Electric.AsyncDeleter do
@moduledoc """
A service 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
import Electric, only: [is_stack_id: 1]
defstruct [
:stack_id,
:interval_ms,
timer_ref: nil,
cleanup_task: nil,
pending: [],
in_progress: []
]
@trash_dir_base ".electric_trash"
@default_cleanup_interval_ms 10_000
def name(stack_id), do: Electric.ProcessRegistry.name(stack_id, __MODULE__)
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 the given directory by first renaming it into the stack's trash directory
then asynchronously removing the trash entry using rm -rf.
"""
@spec delete(Electric.stack_id(), Path.t()) :: :ok | {:error, term()}
def delete(stack_id, path) when is_stack_id(stack_id) and is_binary(path) do
trash_dir = trash_dir!(stack_id)
case do_rename(path, trash_dir) do
{:ok, _dest} ->
GenServer.cast(name(stack_id), {:schedule_cleanup, path})
:ok
{:error, :enoent} ->
Logger.debug("AsyncDeleter: path already gone #{path}")
:ok
{:error, reason} ->
# If this is happening then there's something bad going on and our
# storage is just accruing.
Logger.error("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_request_handler, stack_id})
Logger.metadata(stack_id: stack_id)
Electric.Telemetry.Sentry.set_tags_context(stack_id: stack_id)
trash_dir = trash_dir!(stack_id)
File.mkdir_p(trash_dir)
state = %__MODULE__{
stack_id: stack_id,
interval_ms: Keyword.get(opts, :cleanup_interval_ms, @default_cleanup_interval_ms),
pending: File.ls!(trash_dir)
}
{:ok, state, {:continue, :initial_cleanup}}
end
@impl true
def handle_continue(:initial_cleanup, state) do
{:noreply, do_cleanup(state)}
end
# schedule cleanup if not already scheduled and no cleanup is running
def handle_continue(:schedule_cleanup, %{timer_ref: nil, cleanup_task: 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
suffix = System.unique_integer([:positive]) |> to_string()
Path.join(trash_dir, base <> "_" <> suffix)
end
@impl true
def handle_info(:perform_delete, %{cleanup_task: nil} = state) do
state = do_cleanup(state)
{:noreply, %{state | timer_ref: nil}}
end
def handle_info(:perform_delete, state) do
Logger.debug("AsyncDeleter: cleanup already in progress, skipping scheduled cleanup")
{:noreply, %{state | timer_ref: nil}}
end
def handle_info({ref, :ok}, %{cleanup_task: {%Task{ref: ref}, start_time}} = state) do
duration = System.monotonic_time(:millisecond) - start_time
Logger.debug(
"AsyncDeleter: deleted #{length(state.in_progress)} paths " <>
"for stack #{state.stack_id} in #{duration}ms"
)
{:noreply, %{state | in_progress: [], cleanup_task: nil}, {:continue, :schedule_cleanup}}
end
def handle_info(
{:DOWN, ref, :process, _pid, reason},
%{cleanup_task: {%Task{ref: ref}, start_time}} = state
) do
duration = System.monotonic_time(:millisecond) - start_time
Logger.warning(
"AsyncDeleter: failed to delete #{length(state.pending)} paths " <>
"for stack #{state.stack_id} after #{duration}ms with reason: #{inspect(reason)}" <>
" - will retry on next scheduled cleanup."
)
{:noreply,
%{
state
| pending: state.in_progress ++ state.pending,
in_progress: [],
cleanup_task: nil
}, {:continue, :schedule_cleanup}}
end
# ignore down messages for normal task termination, already handled in result message
def handle_info({:DOWN, _ref, :process, _pid, :normal}, state), do: {:noreply, state}
@impl true
def terminate(reason, state) do
# We want to avoid AsyncDeleter being brought back up while a cleanup task is still running,
# which could lead to concurrent `rm_rf` calls on the trash directory, so we explicitly kill
# it as part of this process termination.
if not is_nil(state.cleanup_task) do
Logger.debug("AsyncDeleter: terminating, killing cleanup task due to #{inspect(reason)}")
{task, _start_time} = state.cleanup_task
Task.shutdown(task, 1_000)
end
end
def trash_dir!(stack_id) do
Electric.StackConfig.lookup!(stack_id, {__MODULE__, :trash_dir})
end
def trash_dir(storage_dir, stack_id), do: Path.join([storage_dir, @trash_dir_base, stack_id])
defp do_rename(path, trash_dir, attempts \\ 3) do
dest = unique_destination(trash_dir, Path.basename(path))
case :prim_file.rename(path, dest) do
:ok -> {:ok, dest}
# in the unlikely event of a name collision, retry with a new name
# rather than incur the cost of ensuring uniqueness on every rename
{:error, :eexist} when attempts > 0 -> do_rename(path, trash_dir, attempts - 1)
{:error, reason} -> {:error, reason}
end
end
defp do_cleanup(%{pending: []} = state), do: state
defp do_cleanup(state) do
start_time = System.monotonic_time(:millisecond)
stack_id = state.stack_id
task =
Task.async(fn ->
Process.set_label({:async_deleter_cleanup_task, stack_id})
Logger.metadata(stack_id: stack_id)
Electric.Telemetry.Sentry.set_tags_context(stack_id: stack_id)
trash_dir = trash_dir!(stack_id)
Logger.debug("AsyncDeleter: Cleaning trash directory #{inspect(trash_dir)}")
try do
clean_dir!(trash_dir)
rescue
e -> Logger.warning("AsyncDeleter: rm_rf failed: #{inspect(e)}")
end
end)
Process.unlink(task.pid)
%{
state
| pending: [],
in_progress: state.pending,
cleanup_task: {task, start_time}
}
end
defp 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