Packages
electric
0.7.5
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.ex
defmodule Electric.ShapeCache.FileStorage do
use Retry
alias Electric.Telemetry.OpenTelemetry
alias Electric.Replication.LogOffset
alias __MODULE__, as: FS
# If the storage format changes, increase `@version` to prevent
# the incompatable older versions being read
@version 2
@version_key :version
@shape_definition_file_name "shape_defintion.json"
@xmin_key :snapshot_xmin
@snapshot_meta_key :snapshot_meta
@snapshot_started_key :snapshot_started
@behaviour Electric.ShapeCache.Storage
defstruct [
:base_path,
:shape_id,
:db,
:cubdb_dir,
:shape_definition_dir,
:snapshot_dir,
version: @version
]
@impl Electric.ShapeCache.Storage
def shared_opts(opts) do
storage_dir = Keyword.get(opts, :storage_dir, "./shapes")
electric_instance_id = Keyword.fetch!(opts, :electric_instance_id)
{:ok, %{base_path: storage_dir, electric_instance_id: electric_instance_id}}
end
@impl Electric.ShapeCache.Storage
def for_shape(shape_id, %FS{shape_id: shape_id} = opts) do
opts
end
def for_shape(shape_id, %{base_path: base_path, electric_instance_id: electric_instance_id}) do
%FS{
base_path: base_path,
shape_id: shape_id,
db: name(electric_instance_id, shape_id),
cubdb_dir: Path.join([base_path, shape_id, "cubdb"]),
snapshot_dir: Path.join([base_path, shape_id, "snapshots"]),
shape_definition_dir: Path.join([base_path, shape_id])
}
end
defp name(electric_instance_id, shape_id) do
Electric.Application.process_name(electric_instance_id, __MODULE__, shape_id)
end
def child_spec(%FS{} = opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :permanent
}
end
@impl Electric.ShapeCache.Storage
def start_link(%FS{cubdb_dir: dir, db: db} = opts) do
with :ok <- initialise_filesystem(opts) do
CubDB.start_link(data_dir: dir, name: db)
end
end
defp initialise_filesystem(opts) do
with :ok <- File.mkdir_p(opts.shape_definition_dir),
:ok <- File.mkdir_p(opts.cubdb_dir),
:ok <- File.mkdir_p(opts.snapshot_dir) do
:ok
end
end
@impl Electric.ShapeCache.Storage
def initialise(%FS{} = opts) do
stored_version = stored_version(opts)
if stored_version != opts.version || snapshot_xmin(opts) == nil ||
not File.exists?(shape_definition_path(opts)) ||
not CubDB.has_key?(opts.db, @snapshot_meta_key) do
cleanup!(opts)
end
CubDB.put(opts.db, @version_key, @version)
end
@impl Electric.ShapeCache.Storage
def set_shape_definition(shape, %FS{} = opts) do
file_path = shape_definition_path(opts)
encoded_shape = Jason.encode!(shape)
case File.write(file_path, encoded_shape, [:exclusive]) do
:ok ->
:ok
{:error, :eexist} ->
# file already exists - by virtue of the shape ID being the hash of the
# definition we do not need to compare them
:ok
{:error, reason} ->
raise "Failed to write shape definition to file: #{reason}"
end
end
@impl Electric.ShapeCache.Storage
def get_all_stored_shapes(%{base_path: base_path}) do
case File.ls(base_path) do
{:ok, shape_ids} ->
Enum.reduce(shape_ids, %{}, fn shape_id, acc ->
shape_def_path =
shape_definition_path(%{shape_definition_dir: Path.join(base_path, shape_id)})
with {:ok, shape_def_encoded} <- File.read(shape_def_path),
{:ok, shape_def_json} <- Jason.decode(shape_def_encoded),
shape = Electric.Shapes.Shape.from_json_safe!(shape_def_json) do
Map.put(acc, shape_id, shape)
else
# if the shape definition file cannot be read/decoded, just ignore it
{:error, _reason} -> acc
end
end)
|> then(&{:ok, &1})
{:error, :enoent} ->
# if not present, there's no stored shapes
{:ok, %{}}
{:error, reason} ->
{:error, reason}
end
end
@impl Electric.ShapeCache.Storage
def get_current_position(%FS{} = opts) do
{:ok, latest_offset(opts), snapshot_xmin(opts)}
end
defp latest_offset(opts) do
case CubDB.select(opts.db,
min_key: log_start(),
max_key: log_end(),
min_key_inclusive: true,
reverse: true
)
|> Enum.take(1) do
[{key, _}] ->
offset(key)
_ ->
LogOffset.first()
end
end
defp snapshot_xmin(opts) do
CubDB.get(opts.db, @xmin_key)
end
@impl Electric.ShapeCache.Storage
def set_snapshot_xmin(xmin, %FS{} = opts) do
CubDB.put(opts.db, @xmin_key, xmin)
end
@impl Electric.ShapeCache.Storage
def snapshot_started?(%FS{} = opts) do
CubDB.has_key?(opts.db, @snapshot_started_key)
end
@impl Electric.ShapeCache.Storage
def mark_snapshot_as_started(%FS{} = opts) do
CubDB.put(opts.db, @snapshot_started_key, true)
end
defp offset({_, tuple_offset}), do: LogOffset.new(tuple_offset)
@impl Electric.ShapeCache.Storage
def make_new_snapshot!(data_stream, %FS{} = opts) do
OpenTelemetry.with_span(
"storage.make_new_snapshot",
[storage_impl: "mixed_disk", "shape.id": opts.shape_id],
fn ->
data_stream
|> Stream.map(&[&1, ?\n])
# Use the 4 byte marker (ASCII "end of transmission") to indicate the end of the snapshot,
# so that concurrent readers can detect that the snapshot has been completed.
|> Stream.concat([<<4::utf8>>])
|> Stream.into(File.stream!(shape_snapshot_path(opts), [:append, :delayed_write]))
|> Stream.run()
CubDB.put(opts.db, @snapshot_meta_key, LogOffset.first())
end
)
end
@impl Electric.ShapeCache.Storage
def get_snapshot(%FS{} = opts) do
if snapshot_started?(opts) do
{LogOffset.first(),
Stream.resource(
fn -> {open_snapshot_file(opts), nil} end,
fn {file, eof_seen} ->
case IO.binread(file, :line) do
{:error, reason} ->
raise IO.StreamError, reason: 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)}}
# If it's been 60s without any new lines, and also we've not seen <<4>>,
# then likely something is wrong
System.monotonic_time(:millisecond) - eof_seen > 60_000 ->
raise "Snapshot hasn't updated in 60s"
true ->
# Sleep a little and check for new lines
Process.sleep(20)
{[], {file, eof_seen}}
end
# The 4 byte marker (ASCII "end of transmission") indicates the end of the snapshot file.
<<4::utf8>> ->
{:halt, {file, nil}}
line ->
{[line], {file, nil}}
end
end,
fn {file, _} -> File.close(file) end
)}
else
raise "Snapshot no longer available"
end
end
defp open_snapshot_file(opts, attempts_left \\ 100)
defp open_snapshot_file(_, 0), do: raise(IO.StreamError, reason: :enoent)
defp open_snapshot_file(opts, attempts_left) do
case File.open(shape_snapshot_path(opts), [:read, :raw, read_ahead: 1024]) do
{:ok, file} ->
file
{:error, :enoent} ->
Process.sleep(10)
open_snapshot_file(opts, attempts_left - 1)
{:error, reason} ->
raise IO.StreamError, reason: reason
end
end
@impl Electric.ShapeCache.Storage
def append_to_log!(log_items, %FS{} = opts) do
retry with: linear_backoff(50, 2) |> expiry(5_000) do
log_items
|> Enum.map(fn
{:chunk_boundary, offset} -> {chunk_checkpoint_key(offset), nil}
{offset, json_log_item} -> {log_key(offset), json_log_item}
end)
|> then(&CubDB.put_multi(opts.db, &1))
else
error -> raise(error)
end
:ok
end
@impl Electric.ShapeCache.Storage
def get_log_stream(offset, max_offset, %FS{} = opts) do
opts.db
|> CubDB.select(
min_key: log_key(offset),
max_key: log_key(max_offset),
min_key_inclusive: false
)
|> Stream.map(fn {_, item} -> item end)
end
@impl Electric.ShapeCache.Storage
def get_chunk_end_log_offset(offset, %FS{} = opts) do
CubDB.select(opts.db,
min_key: chunk_checkpoint_key(offset),
max_key: chunk_checkpoint_end(),
min_key_inclusive: false
)
|> Stream.map(fn {key, _} -> offset(key) end)
|> Enum.take(1)
|> Enum.at(0)
end
@impl Electric.ShapeCache.Storage
def cleanup!(%FS{} = opts) do
[
@snapshot_meta_key,
@xmin_key,
@snapshot_started_key
]
|> Enum.concat(keys_from_range(log_start(), log_end(), opts))
|> Enum.concat(keys_from_range(chunk_checkpoint_start(), chunk_checkpoint_end(), opts))
|> then(&CubDB.delete_multi(opts.db, &1))
{:ok, _} = File.rm_rf(shape_snapshot_path(opts))
{:ok, _} = File.rm_rf(shape_definition_path(opts))
:ok
end
defp shape_definition_path(%{shape_definition_dir: shape_definition_dir} = _opts) do
Path.join(shape_definition_dir, @shape_definition_file_name)
end
defp keys_from_range(min_key, max_key, opts) do
CubDB.select(opts.db, min_key: min_key, max_key: max_key)
|> Stream.map(&elem(&1, 0))
end
defp shape_snapshot_path(opts) do
Path.join([opts.snapshot_dir, "snapshot.jsonl"])
end
defp stored_version(opts) do
CubDB.get(opts.db, @version_key)
end
# Key helpers
defp log_key(offset), do: {:log, LogOffset.to_tuple(offset)}
defp log_start, do: log_key(LogOffset.first())
defp log_end, do: log_key(LogOffset.last())
defp chunk_checkpoint_key(offset), do: {:chunk, LogOffset.to_tuple(offset)}
defp chunk_checkpoint_start(), do: chunk_checkpoint_key(LogOffset.first())
defp chunk_checkpoint_end(), do: chunk_checkpoint_key(LogOffset.last())
end