Current section
Files
Jump to
Current section
Files
lib/rafted_value/persistence.ex
use Croma
defmodule RaftedValue.Persistence do
alias RaftedValue.{TermNumber, LogIndex, LogEntry, Snapshot}
defmodule SnapshotMetadata do
use Croma.Struct, fields: [
path: Croma.String,
term: TermNumber,
last_committed_index: LogIndex,
size: Croma.PosInteger,
]
end
use Croma.Struct, fields: [
dir: Croma.String,
log_fd: Croma.TypeGen.nilable(Croma.Tuple), # This field is `nil` only during initialization (within `new_with_initial_snapshotting/2`)
log_size_written: Croma.NonNegInteger,
log_expansion_factor: Croma.Number,
latest_snapshot_metadata: Croma.TypeGen.nilable(SnapshotMetadata), # This field is `nil` only between startup and first snapshot
snapshot_writer: Croma.TypeGen.nilable(Croma.Pid),
]
defun new_with_initial_snapshotting(dir :: Path.t, factor :: number, snapshot :: Snapshot.t) :: t do
File.mkdir_p!(dir)
{_, index_first, _, _} = entry_elected = snapshot.last_committed_entry
%__MODULE__{dir: dir, log_size_written: 0, log_expansion_factor: factor} # `log_fd` will be filled soon
|> switch_log_file_and_spawn_snapshot_writer(snapshot, index_first)
|> write_log_entries([entry_elected])
end
defun new_with_disk_snapshot(dir :: Path.t, factor :: number, meta :: SnapshotMetadata.t, {_, index_first, _, _} = entry_restore :: LogEntry.t) :: t do
%__MODULE__{dir: dir, log_fd: open_log_file(dir, index_first), log_size_written: 0, log_expansion_factor: factor, latest_snapshot_metadata: meta}
|> write_log_entries([entry_restore])
end
defun new_with_snapshot_sent_from_leader(dir :: Path.t, factor :: number, snapshot :: Snapshot.t) :: t do
File.mkdir_p!(dir)
{_, index_snapshot, _, _} = snapshot.last_committed_entry
%__MODULE__{dir: dir, log_size_written: 0, log_expansion_factor: factor} # `log_fd` will be filled soon
|> switch_log_file_and_spawn_snapshot_writer(snapshot, index_snapshot + 1)
end
defun unset_snapshot_metadata(p :: t) :: t do
%__MODULE__{p | latest_snapshot_metadata: nil}
end
defun write_log_entries(%__MODULE__{log_fd: fd, log_size_written: size} = p, entries :: [LogEntry.t]) :: t do
bin = Enum.map(entries, &LogEntry.to_binary/1) |> :erlang.iolist_to_binary()
:ok = :file.write(fd, bin)
%__MODULE__{p | log_size_written: size + byte_size(bin)}
end
defun log_compaction_runnable?(%__MODULE__{latest_snapshot_metadata: meta,
log_size_written: size_l,
log_expansion_factor: factor,
snapshot_writer: writer}) :: boolean do
if is_pid(writer) do
false
else
case meta do
nil -> true
%SnapshotMetadata{size: size_s} -> size_s * factor < size_l
end
end
end
defun switch_log_file_and_spawn_snapshot_writer(%__MODULE__{dir: dir, log_fd: log_fd1} = persistence,
snapshot :: Snapshot.t,
index_next :: LogIndex.t) :: t do
if log_fd1 do
:ok = :file.close(log_fd1)
end
server_pid = self()
log_fd2 = open_log_file(dir, index_next)
{pid, _ref} = spawn_monitor(fn ->
write_snapshot(snapshot, dir, server_pid)
end)
%__MODULE__{persistence | log_fd: log_fd2, log_size_written: 0, snapshot_writer: pid}
end
defp open_log_file(dir, index_next) do
log_path = Path.join(dir, "log_#{index_next}")
File.open!(log_path, [:write, :sync, :raw])
end
defp write_snapshot(%Snapshot{term: term,
last_committed_entry: {_, last_committed_index, _, _}} = snapshot,
dir,
server_pid) do
snapshot_basename = "snapshot_#{term}_#{last_committed_index}"
snapshot_path = Path.join(dir, snapshot_basename)
compressed = Snapshot.encode(snapshot)
File.write!(snapshot_path, compressed)
# notify the gen_statem process (we have to wait for reply in order to ensure that older snapshots won't be used anymore)
message = {:snapshot_created, snapshot_path, term, last_committed_index, byte_size(compressed)}
:ok = :gen_statem.call(server_pid, message, :infinity)
# cleanup obsolete snapshots and logs
find_obsolete_snapshots(dir, snapshot_basename)
|> Enum.each(&File.rm!/1)
find_log_files_with_committed_entries_only(dir, last_committed_index)
|> Enum.each(&File.rm!/1)
end
defunp find_obsolete_snapshots(dir :: Path.t, snapshot_basename :: String.t) :: [Path.t] do
list_snapshots_in(dir)
|> Enum.filter(fn path -> Path.basename(path) != snapshot_basename end)
|> Enum.drop(1) # Keep one more latest snapshot
end
defun list_snapshots_in(dir :: Path.t) :: [Path.t] do
Path.wildcard(Path.join(dir, "snapshot_*"))
|> Enum.sort_by(&snapshot_path_to_commit_index/1, &>=/2) # newest first
end
defunp snapshot_path_to_commit_index(p :: Path.t) :: LogIndex.t do
["snapshot", _term, index] = Path.basename(p) |> String.split("_")
String.to_integer(index)
end
defunp find_log_files_with_committed_entries_only(dir :: Path.t, i_committed :: LogIndex.t) :: [Path.t] do
partition_obsolete_and_live_log_files(dir, i_committed)
|> elem(0)
|> Enum.map(&elem(&1, 0))
end
defun find_log_files_containing_uncommitted_entries(dir :: Path.t, i_committed :: LogIndex.t) :: [Path.t] do
partition_obsolete_and_live_log_files(dir, i_committed)
|> elem(1)
|> Enum.map(&elem(&1, 0))
end
defunp partition_obsolete_and_live_log_files(dir :: Path.t, i_committed :: LogIndex.t) :: {[{Path.t, LogIndex.t}], [{Path.t, LogIndex.t}]} do
Path.wildcard(Path.join(dir, "log_*"))
|> Enum.map(fn path -> {extract_first_log_index_from_path(path), path} end)
|> Enum.sort()
|> Enum.chunk_every(2, 1, [nil])
|> Enum.map(fn
[{_, path}, {i_next, _}] -> {path, i_next - 1}
[{_, path}, nil ] -> {path, :infinity } # atom is larger than any integers
end)
|> Enum.split_with(fn {_, i} -> i < i_committed end)
end
defun read_last_log_index(dir :: Path.t) :: nil | LogIndex.t do
case Path.wildcard(Path.join(dir, "log_*")) do
[] -> nil
paths ->
latest_log_path = Enum.max_by(paths, &extract_first_log_index_from_path/1)
LogEntry.read_last_entry_index(latest_log_path)
end
end
defunp extract_first_log_index_from_path(path :: Path.t) :: LogIndex.t do
["log", index_first] = Path.basename(path) |> String.split("_")
String.to_integer(index_first)
end
end