Packages
electric
1.1.13
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/shape_cache/file_storage/log_file.ex
defmodule Electric.ShapeCache.FileStorage.LogFile do
@moduledoc false
alias Electric.ShapeCache.FileStorage.KeyIndex
alias Electric.LogItems
alias Electric.ShapeCache.FileStorage.ActionFile
alias Electric.ShapeCache.FileStorage.ChunkIndex
alias Electric.Replication.LogOffset
alias Electric.ShapeCache.LogChunker
# 16 bytes offset + 4 bytes key size + 1 byte op type + 1 byte processed flag + 8 bytes json size = 30 bytes
@line_overhead 16 + 4 + 1 + 1 + 8
@type operation_type() :: :insert | :update | :delete
@type op_type() :: ?u | ?i | ?d
# We're allowing tuple offsets to avoid struct creation in the hot path
@type offset() ::
{tx_offset :: non_neg_integer(), op_offset :: non_neg_integer()} | LogOffset.t()
@typedoc "Log item that can be written to the log file"
@type normal_log_item() ::
{offset(), key :: String.t(), op_type :: operation_type(), json :: String.t()}
@typedoc """
Log item that can be read from the log file, but with precomputed
`byte_size(key)` and `byte_size(json)` values, and with `op_type` as a byte
"""
@type log_item_with_sizes() ::
{offset(), key_size :: non_neg_integer(), key :: String.t(), op_type :: op_type(),
processed_flag :: non_neg_integer(), json_size :: non_neg_integer(),
json :: String.t()}
@type log_item() :: normal_log_item() | log_item_with_sizes()
@typedoc """
Paths to the log file, chunk index, and key index files, used in conjuction
"""
@type log_and_supporting() ::
{log_file_path :: String.t(), chunk_index_path :: String.t(),
key_index_path :: String.t()}
@doc """
Write a log file based on the stream of log items.
Writes 2 files: the log file itself and the chunk index alongside it.
The log file structure is, in elixir binary:
<<tx_offset::64, op_offset::64,
key_size::32, key::binary-size(key_size),
op_type::binary-size(1),
processed_flag::8,
json_size::64, json::binary-size(json_size)>>
"""
@spec write_log_file(
log_stream :: Enumerable.t(log_item()),
log_file_path :: String.t(),
chunk_size :: non_neg_integer()
) :: log_and_supporting()
def write_log_file(
log_stream,
log_file_path,
chunk_size \\ LogChunker.default_chunk_size_threshold()
) do
log_stream
|> normalize_log_stream()
|> ChunkIndex.write_from_stream(log_file_path <> ".chunk_index", chunk_size)
|> KeyIndex.write_from_stream(log_file_path <> ".key_index")
|> Stream.map(fn
{log_offset, key_size, key, op_type, flag, json_size, json} ->
# Add processed flag (0 for unprocessed) to header
[
<<offset(log_offset)::binary, key_size::32, key::binary, op_type::8, flag::8,
json_size::64>>,
json
]
end)
|> Enum.into(File.stream!(log_file_path))
{log_file_path, log_file_path <> ".chunk_index", log_file_path <> ".key_index"}
end
@doc """
Apply the compaction actions to the log file
"""
def apply_actions(
log_file_path,
action_file_path,
chunk_size \\ LogChunker.default_chunk_size_threshold(),
merge_updates_fun \\ &LogItems.merge_updates/2
) do
compacted_log_file_path = log_file_path <> ".compacted"
ActionFile.stream(action_file_path)
|> Stream.transform(
fn -> File.open!(log_file_path, [:read, :raw, :read_ahead]) end,
fn
{_, :skip}, file ->
_ = read_line(file)
{[], file}
{_, :keep}, file ->
case read_line(file) do
{offset, key_size, key, op_type, 0, _json_size, json} ->
# First compaction - process JSON and mark as processed
processed_json = process_json(json)
new_line =
{offset, key_size, key, op_type, 1, byte_size(processed_json), processed_json}
{[new_line], file}
line ->
# Already processed or not insert/delete - keep as-is
{[line], file}
end
{_, {:compact, offsets}}, file ->
{[compact_log_file_lines(file, offsets, merge_updates_fun)], file}
end,
&File.close(&1)
)
|> write_log_file(compacted_log_file_path, chunk_size)
end
defp read_line(file) do
with <<tx_offset::64, op_offset::64, key_size::32>> <- IO.binread(file, 20),
<<key::binary-size(key_size)>> <- IO.binread(file, key_size),
<<op_type::8, processed_flag::8, json_size::64>> <- IO.binread(file, 10),
<<json::binary-size(json_size)>> <- IO.binread(file, json_size) do
{{tx_offset, op_offset}, key_size, key, op_type, processed_flag, json_size, json}
end
end
@spec compact_log_file_lines(
:file.io_device(),
[{position :: non_neg_integer(), size :: non_neg_integer()}],
(elem, elem -> elem)
) :: log_item_with_sizes()
when elem: var
defp compact_log_file_lines(file, file_offsets, merge_updates_fun) do
# The line to be replaced with compaction will keep it's offset & key
{offset, key_size, key, op_type, _, _, _} = read_line(file)
# Save position
{:ok, current_position} = :file.position(file, :cur)
merged_json =
file_offsets
# Group reads to be efficient, but try to limit loading the JSONs to 10MB at a time.
# In the worst case when JSONs exceed 10MB, we'll just read one at a time.
|> chunk_expected_reads(bytes: 1024 * 1024 * 10)
|> Stream.flat_map(fn offsets ->
case :file.pread(file, offsets) do
{:ok, results} -> results
{:error, reason} -> raise inspect(reason)
:eof -> raise "unexpected end of file while reading back jsons from the log"
end
end)
|> Stream.map(&Jason.decode!/1)
|> Enum.reduce(fn new, acc -> merge_updates_fun.(acc, new) end)
|> Jason.encode!()
# Restore position to continue reading in the outer loop
{:ok, _} = :file.position(file, {:bof, current_position})
{offset, key_size, key, op_type, 1, byte_size(merged_json), merged_json}
end
@doc """
Normalize the log stream to have precomputed key and json sizes.
"""
@spec normalize_log_stream(Enumerable.t(log_item())) :: Enumerable.t(log_item_with_sizes())
def normalize_log_stream(stream) do
Stream.map(stream, fn
{log_offset, key, op_type, json} ->
{log_offset, byte_size(key), key, get_op_type(op_type), 0, byte_size(json), json}
{_, _, _, _, _, _, _} = formed_line ->
formed_line
end)
end
@spec chunk_expected_reads(
Enumerable.t({position :: non_neg_integer(), size :: non_neg_integer()}),
bytes: non_neg_integer()
) :: Enumerable.t(list({position :: non_neg_integer(), size :: non_neg_integer()}))
defp chunk_expected_reads(stream, bytes: chunk_size) do
Stream.chunk_while(
stream,
{0, []},
fn
{_, size} = item, {total_size, acc} when total_size > chunk_size ->
{:cont, Enum.reverse(acc), {size, [item]}}
{_, size} = item, {total_size, acc} ->
{:cont, {total_size + size, [item | acc]}}
end,
fn
{_, []} -> {:cont, []}
{_, acc} -> {:cont, Enum.reverse(acc), []}
end
)
end
@doc """
Get the expected byte position in the file after the given log item is written.
Used by other modules that know the log file structure.
"""
@spec expected_position(non_neg_integer(), log_item_with_sizes()) :: non_neg_integer()
def expected_position(
current_position,
{_log_offset, key_size, _key, _op_type, _processed_flag, json_size, _json}
) do
current_position + key_size + json_size + @line_overhead
end
@doc """
Get the expected byte position of the JSON for the given log item after it's written.
Used by other modules that know the log file structure.
"""
@spec expected_json_position(non_neg_integer(), log_item_with_sizes()) :: non_neg_integer()
def expected_json_position(current_position, {_, key_size, _, _, _, _, _}) do
current_position + key_size + @line_overhead
end
@doc """
Read a chunk of the log file from the given offset.
Returns a stream of json strings.
"""
@spec read_chunk(log :: log_and_supporting(), LogOffset.t()) :: Enumerable.t(String.t())
def read_chunk({log_file_path, chunk_index_path, _key_index_path}, %LogOffset{} = offset) do
case ChunkIndex.fetch_chunk(chunk_index_path, offset) do
{:ok, _max_offset, {start_position, end_position}} ->
stream_jsons(log_file_path, start_position, end_position, offset)
:error ->
[]
end
end
defp stream_jsons(log_file_path, start_position, end_position, exclusive_min_offset) do
# We can read ahead entire chunk into memory since chunk sizes are expected to be ~10MB by default,
file = File.open!(log_file_path, [:read, :raw])
try do
with {:ok, data} <- :file.pread(file, start_position, end_position - start_position) do
extract_jsons_from_binary(data, exclusive_min_offset)
else
:eof -> raise "unexpected end of file"
{:error, reason} -> raise "error reading file: #{inspect(reason)}"
end
after
File.close(file)
end
end
@spec extract_jsons_from_binary(binary(), LogOffset.t()) :: Enumerable.t(String.t())
defp extract_jsons_from_binary(binary, exclusive_min_offset, acc \\ [])
defp extract_jsons_from_binary(<<>>, _, acc), do: Enum.reverse(acc)
defp extract_jsons_from_binary(
<<tx_offset1::64, op_offset1::64, key_size::32, _::binary-size(key_size), _::8, _flag::8,
json_size::64, _::binary-size(json_size), rest::binary>>,
%LogOffset{
tx_offset: tx_offset2,
op_offset: op_offset2
} = log_offset,
acc
)
when tx_offset1 < tx_offset2 or (tx_offset1 == tx_offset2 and op_offset1 <= op_offset2),
do: extract_jsons_from_binary(rest, log_offset, acc)
defp extract_jsons_from_binary(
<<_::128, key_size::32, _::binary-size(key_size), _::8, _flag::8, json_size::64,
json::binary-size(json_size), rest::binary>>,
log_offset,
acc
),
do: extract_jsons_from_binary(rest, log_offset, [json | acc])
defp get_op_type(:insert), do: ?i
defp get_op_type(:update), do: ?u
defp get_op_type(:delete), do: ?d
@doc "Serialize a non-infinite non-negative offset to a 16-byte binary"
@spec offset(offset()) :: binary
def offset(%LogOffset{tx_offset: tx_offset, op_offset: op_offset}),
do: <<tx_offset::64, op_offset::64>>
def offset({tx_offset, op_offset}), do: <<tx_offset::64, op_offset::64>>
defp process_json(json) do
json
|> Jason.decode!()
|> LogItems.keep_generic_headers()
|> Jason.encode!()
end
end