Packages
electric
1.2.3
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/pure_file_storage/snapshot.ex
defmodule Electric.ShapeCache.PureFileStorage.Snapshot do
alias Electric.ShapeCache.PureFileStorage, as: ST
alias Electric.ShapeCache.Storage
@moduledoc false
@write_buffer_size 64 * 1024
@doc """
Write the stream of pre-formatted JSON lines of the initial query into the snapshot files.
Snapshot storage is slightly different from main file storage, because
our system requires one additional property: reads concurrent with writes.
Because snapshots are a common operation, the fastest way to serve them to a client
is to stream them out as they are being written.
To fascilitate this property without overcomplicating the system, the snapshot chunks
are stored as separate files as they are being written, with a `0x04` byte (end-of-transmission)
appended to the end. Apart from that, the file is json-chunk formatted (jsonl with trailing commas)
to allow for socket copies later on.
The clients of the storage expect the snapshot chunks to be read in their entirety, which gives us freedom
to do batched writes for performance, because reads are always going to be faster than we're writing.
"""
def write_snapshot_stream!(stream, %ST{} = opts, write_buffer \\ @write_buffer_size) do
stream
|> Stream.transform(
fn -> {0, nil, {[], 0}} end,
fn line, {chunk_num, file, {buffer, buffer_size}} ->
file = file || open_snapshot_chunk_to_write(opts, chunk_num)
case line do
:chunk_boundary ->
IO.binwrite(file, [buffer, <<4::utf8>>])
File.close(file)
{[], {chunk_num + 1, nil, {[], 0}}}
line ->
line_size = IO.iodata_length(line)
if buffer_size + line_size > write_buffer do
IO.binwrite(file, [buffer, line, ",\n"])
{[chunk_num], {chunk_num, file, {[], 0}}}
else
{[chunk_num],
{chunk_num, file, {[buffer, line, ",\n"], buffer_size + line_size + 2}}}
end
end
end,
fn {chunk_num, file, {buffer, _}} ->
cond do
not is_nil(file) ->
IO.binwrite(file, [buffer, <<4::utf8>>])
{[chunk_num], {chunk_num, file, {[], 0}}}
is_nil(file) and chunk_num == 0 ->
# Special case if the source stream has ended before we started writing any chunks - we need to create the empty file for the first chunk.
file = open_snapshot_chunk_to_write(opts, chunk_num)
IO.binwrite(file, [buffer, <<4::utf8>>])
{[chunk_num], {chunk_num, file, {[], 0}}}
true ->
{[chunk_num - 1], {chunk_num, file, {[], 0}}}
end
end,
fn {_chunk_num, file, _} ->
if file, do: File.close(file)
end
)
|> Enum.reduce(0, fn chunk_num, _ -> chunk_num end)
end
def chunk_file_path(%ST{} = opts, chunk_num),
do: ST.shape_log_path(opts, "#{chunk_num}.jsonsnapshot")
defp open_snapshot_chunk_to_write(opts, chunk_num) do
File.open!(chunk_file_path(opts, chunk_num), [:write, :exclusive, :raw])
end
@doc """
Stream JSON lines of a given chunk.
Streams out the lines without trailing commas or line breaks. If chunk is in the process
of being written, then follows it as it's being written emitting lines as they appear.
If chunk file still doesn't exist, will wait for up to 5 seconds for the file to appear.
"""
def stream_chunk_lines(%ST{} = opts, chunk_num) do
path = chunk_file_path(opts, chunk_num)
Stream.resource(
fn -> {wait_and_open!(path, [:raw, :read, :read_ahead]), nil, ""} end,
fn {file, eof_seen, incomplete_line} ->
case IO.binread(file, :line) do
{:error, reason} ->
raise Storage.Error, message: "failed to read #{inspect(path)}: #{inspect(reason)}"
:eof ->
cond do
is_nil(eof_seen) ->
# First time we see eof after any valid lines, we store a timestamp
{[], {file, System.monotonic_time(:millisecond), incomplete_line}}
# If it's been 90s without any new lines, and also we've not seen <<4>>,
# then likely something is wrong
System.monotonic_time(:millisecond) - eof_seen > 90_000 ->
raise Storage.Error, message: "Snapshot hasn't updated in 90s"
true ->
# Sleep a little and check for new lines
Process.sleep(20)
{[], {file, eof_seen, incomplete_line}}
end
# The 4 byte marker (ASCII "end of transmission") indicates the end of the snapshot file.
<<4::utf8>> ->
{:halt, {file, nil, ""}}
line ->
cond do
:binary.last(line) != ?\n ->
# Not a full line
{[], {file, nil, incomplete_line <> line}}
line == "\n" ->
{[binary_slice(incomplete_line, 0..-2//1)], {file, nil, ""}}
true ->
{[incomplete_line <> binary_slice(line, 0..-3//1)], {file, nil, ""}}
end
end
end,
&File.close(elem(&1, 0))
)
end
defp wait_and_open!(path, modes, time_left \\ :timer.seconds(5))
defp wait_and_open!(path, _, time_left) when time_left <= 0,
do: raise(Storage.Error, message: "failed to open #{path}: :enoent")
defp wait_and_open!(path, modes, time_left) do
start = System.monotonic_time(:millisecond)
case File.open(path, modes) do
{:ok, file} ->
file
{:error, :enoent} ->
Process.sleep(20)
wait_and_open!(path, modes, time_left - (System.monotonic_time(:millisecond) - start))
{:error, reason} ->
raise(Storage.Error, message: "failed to open #{path}: #{inspect(reason)}")
end
end
end