Packages
electric
1.0.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/file_storage.ex
defmodule Electric.ShapeCache.FileStorage do
use Retry
require Logger
alias Electric.ShapeCache.LogChunker
alias Electric.Telemetry.OpenTelemetry
alias Electric.Replication.LogOffset
import Electric.Replication.LogOffset, only: :macros
alias __MODULE__, as: FS
# If the storage format changes, increase `@version` to prevent
# the incompatable older versions being read
@version 3
@version_key :version
@shape_definition_file_name "shape_defintion.json"
@xmin_key :snapshot_xmin
@pg_snapshot_key :pg_snapshot
@snapshot_meta_key :snapshot_meta
@snapshot_started_key :snapshot_started
@compaction_info_key :compaction_info
@behaviour Electric.ShapeCache.Storage
defstruct [
:base_path,
:shape_handle,
:db,
:data_dir,
:cubdb_dir,
:snapshot_dir,
:log_dir,
:stack_id,
:extra_opts,
:chunk_bytes_threshold,
version: @version
]
@impl Electric.ShapeCache.Storage
def shared_opts(opts) do
stack_id = Keyword.fetch!(opts, :stack_id)
storage_dir = Keyword.get(opts, :storage_dir, "./shapes")
# Always scope the provided storage dir by stack id
%{
base_path: Path.join(storage_dir, stack_id),
stack_id: stack_id,
chunk_bytes_threshold:
Keyword.get(opts, :chunk_bytes_threshold, LogChunker.default_chunk_size_threshold())
}
end
@impl Electric.ShapeCache.Storage
def for_shape(shape_handle, %FS{shape_handle: shape_handle} = opts) do
opts
end
def for_shape(
shape_handle,
%{base_path: base_path, stack_id: stack_id} = opts
) do
data_dir = Path.join([base_path, shape_handle])
%FS{
base_path: base_path,
shape_handle: shape_handle,
db: name(stack_id, shape_handle),
data_dir: data_dir,
cubdb_dir: Path.join([data_dir, "cubdb"]),
snapshot_dir: Path.join([data_dir, "snapshots"]),
log_dir: Path.join([data_dir, "log"]),
stack_id: stack_id,
extra_opts: Map.get(opts, :extra_opts, %{}),
chunk_bytes_threshold: opts.chunk_bytes_threshold
}
end
defp name(stack_id, shape_handle) do
Electric.ProcessRegistry.name(stack_id, __MODULE__, shape_handle)
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,
hibernate_after: Electric.Config.get_env(:shape_hibernate_after)
)
end
end
defp initialise_filesystem(opts) do
with :ok <- File.mkdir_p(opts.data_dir),
:ok <- File.mkdir_p(opts.cubdb_dir),
:ok <- File.mkdir_p(opts.snapshot_dir),
:ok <- File.mkdir_p(opts.log_dir) do
:ok
end
end
@impl Electric.ShapeCache.Storage
def initialise(%FS{} = opts) do
stored_version = stored_version(opts)
if stored_version != opts.version || is_nil(pg_snapshot(opts)) ||
not File.exists?(shape_definition_path(opts)) ||
not CubDB.has_key?(opts.db, @snapshot_meta_key) do
cleanup_internals!(opts)
end
if File.exists?(old_snapshot_path(opts)) do
# This shape has had the snapshot written before we started using the new format.
# We need to move the old snapshot into the new format and store correct metadata
# so that we know it's complete.
File.rename(old_snapshot_path(opts), snapshot_chunk_path(opts, 0))
CubDB.put(opts.db, @snapshot_meta_key, LogOffset.new(0, 0))
end
CubDB.put(opts.db, @version_key, @version)
end
defp old_snapshot_path(opts) do
Path.join([opts.snapshot_dir, "snapshot.jsonl"])
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 handle 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(opts) do
shapes_dir = opts.base_path
case File.ls(shapes_dir) do
{:ok, shape_handles} ->
Enum.reduce(shape_handles, %{}, fn shape_handle, acc ->
shape_def_path =
shape_definition_path(%{
data_dir: Path.join([opts.base_path, shape_handle])
})
with {:ok, shape_def_encoded} <- File.read(shape_def_path),
{:ok, shape_def_json} <- Jason.decode(shape_def_encoded),
{:ok, shape} <- Electric.Shapes.Shape.from_json_safe(shape_def_json) do
Map.put(acc, shape_handle, 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_total_disk_usage(%{base_path: shapes_dir} = opts) do
case File.ls(shapes_dir) do
{:ok, shape_handles} ->
shape_handles
|> Enum.map(&for_shape(&1, opts))
|> Enum.map(fn fs ->
maybe_get_size(shape_definition_path(fs)) +
get_all_chunk_sizes(fs) +
maybe_get_size(CubDB.current_db_file(fs.db))
end)
|> Enum.sum()
_ ->
0
end
end
defp get_all_chunk_sizes(%FS{} = opts) do
case File.ls(opts.snapshot_dir) do
{:ok, chunk_files} -> chunk_files |> Enum.map(&maybe_get_size/1) |> Enum.sum()
_ -> 0
end
end
defp maybe_get_size(path) do
case File.stat(path) do
{:ok, stat} -> stat.size
{:error, _} -> 0
end
end
@impl Electric.ShapeCache.Storage
def get_current_position(%FS{} = opts) do
{:ok, latest_offset(opts), pg_snapshot(opts)}
end
defp latest_offset(opts) do
with nil <- get_latest_txn_log_offset(opts),
nil <- get_last_snapshot_offset(opts) do
# We're returning this very fake offset here due to our system's limitation: for ongoing log, we read "latest offset"
# from an ETS table in the shape cache, that's updated by the consumer as it consumes the log. I don't want to introduce
# any responsibility to chat to the consumer into the storage, so this gives the logic in the plug a good reference point
# to compare snapshot offsets to. They are always going to be smaller than this, so we will be able to go through the chunks.
LogOffset.last_before_real_offsets()
end
end
defp get_latest_txn_log_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)
_ -> nil
end
end
defp pg_snapshot(opts) do
# Temporary fallback to @xmin_key until we do a breaking release that drops that key entirely.
with nil <- CubDB.get(opts.db, @pg_snapshot_key),
xmin when not is_nil(xmin) <- CubDB.get(opts.db, @xmin_key) do
%{xmin: xmin, xmax: xmin + 1, xip_list: [xmin], filter_txns?: true}
end
end
@impl Electric.ShapeCache.Storage
def set_pg_snapshot(pg_snapshot, %FS{} = opts) do
CubDB.put(opts.db, @pg_snapshot_key, pg_snapshot)
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{stack_id: stack_id} = opts) do
OpenTelemetry.with_span(
"storage.make_new_snapshot",
[storage_impl: "mixed_disk", "shape.handle": opts.shape_handle],
stack_id,
fn ->
last_chunk_num = write_stream_to_chunk_files(data_stream, opts)
CubDB.put(opts.db, @snapshot_meta_key, LogOffset.new(0, last_chunk_num))
end
)
end
# Write to a set of "chunk" files, with numbering starting from 0, and return the highest chunk number
defp write_stream_to_chunk_files(data_stream, opts) do
data_stream
|> Stream.transform(
fn -> {0, nil} end,
fn line, {chunk_num, file} ->
file = file || open_snapshot_chunk_to_write(opts, chunk_num)
case line do
:chunk_boundary ->
# 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. This is a way to
# distinguish between "file quiet" and "file done".
IO.binwrite(file, <<4::utf8>>)
File.close(file)
{[], {chunk_num + 1, nil}}
line ->
IO.binwrite(file, [line, ?\n])
{[chunk_num], {chunk_num, file}}
end
end,
fn {chunk_num, file} ->
if is_nil(file) and chunk_num == 0 do
# 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.
{[chunk_num], {chunk_num, open_snapshot_chunk_to_write(opts, chunk_num)}}
else
{[], {chunk_num, file}}
end
end,
fn {_chunk_num, file} ->
if file do
IO.binwrite(file, <<4::utf8>>)
File.close(file)
end
end
)
|> Enum.reduce(0, fn chunk_num, _ -> chunk_num end)
end
defp open_snapshot_chunk_to_write(opts, chunk_number) do
Logger.debug("Opening snapshot chunk #{chunk_number} for writing",
shape_handle: opts.shape_handle,
stack_id: opts.stack_id
)
File.open!(snapshot_chunk_path(opts, chunk_number), [:write, :raw])
end
def snapshot_chunk_path(opts, chunk_number)
when is_integer(chunk_number) and chunk_number >= 0 do
Path.join([opts.snapshot_dir, "snapshot_chunk.#{chunk_number}.jsonl"])
end
@impl Electric.ShapeCache.Storage
def append_to_log!(log_items, %FS{} = opts) do
compaction_boundary = get_compaction_boundary(opts)
retry with: linear_backoff(50, 2) |> expiry(5_000) do
log_items
|> Enum.flat_map(fn
{:chunk_boundary, offset} ->
[{chunk_checkpoint_key(offset), nil}]
# We have definitely seen this, but it's not going to be in CubDB after compaction,
# so instead of idempotent insert we just ignore.
{offset, _, _, _} when is_log_offset_lt(offset, compaction_boundary) ->
[]
{offset, key, op_type, json_log_item} ->
[{log_key(offset), {key, op_type, 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(
%LogOffset{tx_offset: tx_offset, op_offset: op_offset} = offset,
max_offset,
%FS{} = opts
)
when tx_offset <= 0 do
unless snapshot_started?(opts), do: raise("Snapshot not started")
case {CubDB.get(opts.db, @snapshot_meta_key), offset} do
# Snapshot is complete
{%LogOffset{}, offset} when is_min_offset(offset) ->
# Stream first chunk of snapshot
stream_snapshot_chunk!(opts, 0)
{%LogOffset{} = latest, offset} when is_log_offset_lt(offset, latest) ->
# Stream next chunk of snapshot
stream_snapshot_chunk!(opts, op_offset + 1)
{%LogOffset{}, offset} ->
stream_log_chunk(offset, max_offset, opts)
# Snapshot is incomplete
{nil, offset} when is_min_offset(offset) ->
stream_snapshot_chunk!(opts, 0)
{nil, _offset} ->
# Try streaming the next chunk if the file already exists, otherwise wait for the file or end of snapshot to be announced
# where either event should happen shortly, we just either hit a file switch or just before CubDB was updatred
wait_for_chunk_file_or_snapshot_end(opts, op_offset + 1)
end
end
# Any offsets with tx offset > 0 are not part of the initial snapshot, no need for additional checks.
def get_log_stream(%LogOffset{} = offset, max_offset, %FS{} = opts),
do: stream_log_chunk(offset, max_offset, opts)
def compact(%FS{} = opts) do
CubDB.select(opts.db,
min_key: chunk_checkpoint_start(),
max_key: chunk_checkpoint_end(),
reverse: true
)
# Keep the last 2 chunks as-is so that anything that relies on the live stream and
# transactional information/LSNs always has something to work with.
|> Enum.take(3)
|> case do
[_, _, {key, _}] ->
compact(opts, offset(key))
_ ->
# Not enough chunks to warrant compaction
:ok
end
end
def compact(%FS{} = opts, %LogOffset{} = upper_bound) do
# We consider log before the stored upper bound live & uncompacted. This means that concurrent readers
# will be able to read out everything they want while the compaction is happening and we're only
# atomically updating the pointer to the live portion.
case CubDB.fetch(opts.db, @compaction_info_key) do
{:ok, {_, ^upper_bound}} ->
:ok
{:ok, {old_log, _}} ->
# compact further
new_log_file_path =
Path.join(
opts.log_dir,
"compact_log_#{DateTime.utc_now() |> DateTime.to_unix(:millisecond)}.electric"
)
new_log =
CubDB.select(opts.db,
min_key: log_start(),
max_key: log_key(upper_bound),
max_key_inclusive: true
)
|> Stream.map(fn {key, {op_key, op_type, json}} ->
{offset(key), op_key, op_type, json}
end)
|> FS.LogFile.write_log_file(new_log_file_path <> ".new")
merged_log =
FS.Compaction.merge_and_compact(
old_log,
new_log,
new_log_file_path,
opts.chunk_bytes_threshold
)
CubDB.put(opts.db, @compaction_info_key, {merged_log, upper_bound})
delete_compacted_keys(opts, upper_bound)
FS.Compaction.rm_log(new_log)
FS.Compaction.rm_log(old_log)
:ok
:error ->
log_file_path = Path.join(opts.log_dir, "compact_log.electric")
log =
CubDB.select(opts.db,
min_key: log_start(),
max_key: log_key(upper_bound),
max_key_inclusive: true
)
|> Stream.map(fn {key, {op_key, op_type, json}} ->
{offset(key), op_key, op_type, json}
end)
|> FS.LogFile.write_log_file(log_file_path)
|> FS.Compaction.compact_in_place(opts.chunk_bytes_threshold)
CubDB.put(opts.db, @compaction_info_key, {log, upper_bound})
delete_compacted_keys(opts, upper_bound)
:ok
end
end
defp delete_compacted_keys(%FS{} = opts, upper_bound) do
compacted_chunks =
CubDB.select(opts.db,
min_key: chunk_checkpoint_start(),
max_key: chunk_checkpoint_key(upper_bound),
max_key_inclusive: true
)
|> Enum.map(fn {key, _} -> key end)
compacted_logs =
CubDB.select(opts.db,
min_key: log_start(),
max_key: log_key(upper_bound)
)
|> Enum.map(fn {key, _} -> key end)
CubDB.delete_multi(opts.db, compacted_chunks ++ compacted_logs)
end
# This function raises if the chunk file doesn't exist.
defp stream_snapshot_chunk!(%FS{} = opts, chunk_number) do
Stream.resource(
fn -> {open_snapshot_chunk(opts, chunk_number), nil, ""} end,
fn {file, eof_seen, incomplete_line} ->
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), incomplete_line}}
# 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, incomplete_line}}
end
# The 4 byte marker (ASCII "end of transmission") indicates the end of the snapshot file.
<<4::utf8>> ->
{:halt, {file, nil, ""}}
line ->
if binary_slice(line, -1, 1) == "\n" do
{[incomplete_line <> line], {file, nil, ""}}
else
{[], {file, nil, incomplete_line <> line}}
end
end
end,
&File.close(elem(&1, 0))
)
end
defp open_snapshot_chunk(opts, chunk_num, attempts_left \\ 100)
defp open_snapshot_chunk(_, _, 0), do: raise(IO.StreamError, reason: :enoent)
defp open_snapshot_chunk(opts, chunk_num, attempts_left) do
case File.open(snapshot_chunk_path(opts, chunk_num), [:read, :raw, read_ahead: 1024]) do
{:ok, file} ->
file
{:error, :enoent} ->
Process.sleep(20)
open_snapshot_chunk(opts, chunk_num, attempts_left - 1)
{:error, reason} ->
raise IO.StreamError, reason: reason
end
end
defp stream_log_chunk(%LogOffset{} = offset, max_offset, %FS{} = opts) do
case CubDB.fetch(opts.db, @compaction_info_key) do
{:ok, {log, upper_bound}} when is_log_offset_lt(offset, upper_bound) ->
FS.ChunkIndex.fetch_chunk(elem(log, 1), offset)
FS.LogFile.read_chunk(log, offset)
_ ->
opts.db
|> CubDB.select(
min_key: log_key(offset),
max_key: log_key(max_offset),
min_key_inclusive: false
)
|> Stream.map(fn {_, {_, _, json_log_item}} -> json_log_item end)
end
end
defp get_compaction_boundary(%FS{} = opts) do
case CubDB.fetch(opts.db, @compaction_info_key) do
{:ok, {_, upper_bound}} -> upper_bound
:error -> LogOffset.first()
end
end
defp wait_for_chunk_file_or_snapshot_end(
opts,
chunk_number,
max_wait_time \\ 60_000,
total_wait_time \\ 0
)
defp wait_for_chunk_file_or_snapshot_end(_, _, max, total) when total >= max,
do: raise("Snapshot hasn't updated in #{max}ms")
defp wait_for_chunk_file_or_snapshot_end(
%FS{} = opts,
chunk_number,
max_wait_time,
total_wait_time
) do
path = snapshot_chunk_path(opts, chunk_number)
cond do
File.exists?(path, [:raw]) ->
stream_snapshot_chunk!(opts, chunk_number)
CubDB.has_key?(opts.db, @snapshot_meta_key) ->
[]
true ->
Process.sleep(50)
wait_for_chunk_file_or_snapshot_end(
opts,
chunk_number,
max_wait_time,
total_wait_time + 50
)
end
end
@impl Electric.ShapeCache.Storage
# If min offset was requested, then next chunk boundary is first snapshot chunk
def get_chunk_end_log_offset(offset, _) when is_min_offset(offset),
do: snapshot_offset(0)
# If the current offset is one of the "real" chunks, then next chunk is the boundary
def get_chunk_end_log_offset(offset, %FS{} = opts) when is_virtual_offset(offset) do
case get_last_snapshot_offset(%FS{} = opts) do
# We don't have the "last one", so optimistically give the next chunk pointer.
# If it turns out we're actually done, then this pointer will give beginning of txn log when requested with.
nil -> LogOffset.increment(offset)
# This is easy - we want to read next chunk and we know we can
last when is_log_offset_lt(offset, last) -> LogOffset.increment(offset)
# Requested chunk is at the end or beyond the end of the snapshot, serve from txn log. If no chunk is yet present, get end of log
_ -> get_chunk_end_for_log(offset, opts)
end
end
# Current offset is in txn log, serve from there.
def get_chunk_end_log_offset(offset, %FS{} = opts), do: get_chunk_end_for_log(offset, opts)
defp get_chunk_end_for_log(offset, %FS{} = opts) do
case CubDB.fetch(opts.db, @compaction_info_key) do
{:ok, {log, upper_bound}} when is_log_offset_lt(offset, upper_bound) ->
{:ok, max_offset, _} = FS.ChunkIndex.fetch_chunk(elem(log, 1), offset)
max_offset
_ ->
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
end
defp get_last_snapshot_offset(%FS{} = opts) do
CubDB.get(opts.db, @snapshot_meta_key)
end
defp cleanup_internals!(%FS{} = opts) do
[
@xmin_key,
@pg_snapshot_key,
@snapshot_meta_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(opts.snapshot_dir)
{:ok, _} = File.rm_rf(shape_definition_path(opts))
:ok = File.mkdir_p!(opts.snapshot_dir)
:ok
end
@impl Electric.ShapeCache.Storage
def cleanup!(%FS{} = opts) do
# can't delete the data_dir here because the CubDb instance is still running,
# and trying to delete the parent directory will fail since the files inside
# are still in use
:ok = cleanup_internals!(opts)
end
@impl Electric.ShapeCache.Storage
def unsafe_cleanup!(%FS{} = opts) do
{:ok, _} = File.rm_rf(opts.snapshot_dir)
{:ok, _} = File.rm_rf(shape_definition_path(opts))
{:ok, _} = File.rm_rf(opts.data_dir)
:ok
end
defp shape_definition_path(%{data_dir: data_dir} = _opts) do
Path.join(data_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 stored_version(opts) do
CubDB.get(opts.db, @version_key)
end
defp snapshot_offset(chunk_number), do: LogOffset.new(0, chunk_number)
# 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