defmodule Goblin do @moduledoc """ A lightweight, embedded, LSM-tree database for Elixir. Goblin is a persistent key-value store with ACID transactions, crash recovery, and automatic background compaction. It runs inside your application's supervision tree. ## Starting a database {:ok, db} = Goblin.start_link( name: MyApp.DB, data_dir: "/path/to/db" ) ## Basic operations Goblin.put(db, :alice, "Alice") Goblin.get(db, :alice) # => "Alice" Goblin.remove(db, :alice) Goblin.get(db, :alice) # => nil ## Batch operations Goblin.put_multi(db, [{:alice, "Alice"}, {:bob, "Bob"}]) Goblin.get_multi(db, [:alice, :bob]) # => [{:alice, "Alice"}, {:bob, "Bob"}] ## Transactions Goblin.transaction(db, fn tx -> counter = Goblin.Tx.get(tx, :counter, default: 0) tx |> Goblin.Tx.put(:counter, counter + 1) |> Goblin.Tx.commit() end) # => :ok See `start_link/1` for configuration options. """ @behaviour :gen_statem alias Goblin.{ DiskTable, Export, FileIO, Merge, Levels, Manifest, MemTable, Tx, MVCC } @goblin_suffix "goblin" @wal_suffix "wal" @default_timeout 5000 @default_flush_level_file_limit 4 @default_mem_limit 64 * 1024 * 1024 @default_level_base_size 256 * 1024 * 1024 @default_level_size_multiplier 10 @default_bit_array_size 10_000 @default_fpp 0.01 defstruct [ :data_dir, :mem_table, :mvcc, :manifest, :file_counter, :opts, :sequence, :writer, levels: %{}, flushing: %{}, compacting: %{}, readers: %{}, writer_queue: :queue.new() ] @doc """ Executes a read-write transaction. Transactions are executed serially and are ACID-compliant. The provided function receives a transaction struct and must return `{:commit, tx, reply}` to commit, or `{:abort, reply}` to abort. ## Parameters - `db` - The database server (PID or registered name) - `callback` - A function that takes a `Goblin.Tx.t()` and returns a transaction result - `opts` - A keyword list with options: - `:timeout` - Timeout (in milliseconds) for the calls (default: `5000`) ## Returns - `reply` - The reply from `{:commit, tx, reply}` when committed - `{:error, :aborted}` - When the transaction is aborted ## Examples Goblin.transaction(db, fn tx -> counter = Goblin.Tx.get(tx, :counter, default: 0) tx |> Goblin.Tx.put(:counter, counter + 1) |> Goblin.Tx.commit() end) # => :ok Goblin.transaction(db, fn tx -> tx |> Goblin.Tx.abort() end) # => :error """ def transaction(db, callback, opts \\ []) do mvcc = get_mvcc(db) tx_key = make_ref() start_transaction(db, tx_key, opts) {seq, max_lk, tx_id} = MVCC.add_reader(mvcc, tx_key) tx = %Tx{ mode: :write, mvcc: mvcc, tx_id: tx_id, sequence: seq, max_level_key: max_lk } result = try do callback.(tx) rescue exception -> cancel_transaction(db, tx_key, opts) reraise(exception, __STACKTRACE__) catch :throw, val -> cancel_transaction(db, tx_key, opts) throw(val) :exit, val -> cancel_transaction(db, tx_key, opts) exit(val) after MVCC.release_reader(mvcc, tx_key) end case result do {:commit, tx, reply} -> with :ok <- commit_transaction(db, tx, opts), do: reply {:abort, reply} -> cancel_transaction(db, tx_key, opts) reply _ -> cancel_transaction(db, tx_key, opts) raise "Invalid return from `Goblin.transaction/2`" end end @doc """ Writes a key-value pair to the database. ## Parameters - `db` - The database server (PID or registered name) - `key` - Any Elixir term to use as the key - `value` - Any Elixir term to be associated with `key` - `opts` - A keyword list with the following options (default: `[]`): - `:tag` - Tag to namespace the key under - `:timeout` - Timeout (in milliseconds) for the calls (default: `5000`) ## Returns - `:ok` ## Examples Goblin.put(db, :alice, "Alice") # => :ok Goblin.put(db, :alice, "Alice", tag: :admins) # => :ok """ def put(db, key, val, opts \\ []) do transaction(db, fn tx -> tx |> Tx.put(key, val, opts) |> Tx.commit() end) end @doc """ Writes multiple key-value pairs in a single transaction. ## Parameters - `db` - The database server (PID or registered name) - `pairs` - A list of `{key, value}` tuples - `opts` - A keyword list with the following options (default: `[]`): - `:tag` - Tag to namespace the keys under - `:timeout` - Timeout (in milliseconds) for the calls (default: `5000`) ## Returns - `:ok` ## Examples Goblin.put_multi(db, [{:alice, "Alice"}, {:bob, "Bob"}, {:charlie, "Charlie"}]) # => :ok """ def put_multi(db, pairs, opts \\ []) do transaction(db, fn tx -> tx |> Tx.put_multi(pairs, opts) |> Tx.commit() end) end @doc """ Removes a key from the database. ## Parameters - `db` - The database server (PID or registered name) - `key` - The key to remove - `opts` - A keyword list with the following options (default: `[]`): - `:tag` - Tag the key is namespaced under - `:timeout` - Timeout (in milliseconds) for the calls (default: `5000`) ## Returns - `:ok` ## Examples Goblin.remove(db, :alice) # => :ok Goblin.get(db, :alice) # => nil """ def remove(db, key, opts \\ []) do transaction(db, fn tx -> tx |> Tx.remove(key, opts) |> Tx.commit() end) end @doc """ Removes multiple keys from the database in a single transaction. ## Parameters - `db` - The database server (PID or registered name) - `keys` - A list of keys to remove - `opts` - A keyword list with the following options (default: `[]`): - `:tag` - Tag the keys are namespaced under - `:timeout` - Timeout (in milliseconds) for the calls (default: `5000`) ## Returns - `:ok` ## Examples Goblin.remove_multi(db, [:alice, :bob, :charlie]) # => :ok """ def remove_multi(db, keys, opts \\ []) do transaction(db, fn tx -> tx |> Tx.remove_multi(keys, opts) |> Tx.commit() end) end @doc """ Performs a read-only transaction. A snapshot is taken to provide a consistent mvcc of the database. Multiple readers run concurrently without blocking each other. Attempting to write within a read transaction raises. ## Parameters - `db` - The database server (PID or registered name) - `callback` - A function that takes a `Goblin.Tx.t()` struct ## Returns - The return value of `callback` ## Examples Goblin.read(db, fn tx -> alice = Goblin.Tx.get(tx, :alice) bob = Goblin.Tx.get(tx, :bob) {alice, bob} end) # => {"Alice", "Bob"} """ def read(db, callback) do mvcc = get_mvcc(db) tx_key = make_ref() :gen_statem.cast(db, {:track_reader, self(), tx_key}) {seq, max_lk, tx_id} = MVCC.add_reader(mvcc, tx_key) tx = %Tx{ mode: :read, mvcc: mvcc, tx_id: tx_id, sequence: seq, max_level_key: max_lk } try do callback.(tx) after MVCC.release_reader(mvcc, tx_key) :gen_statem.cast(db, {:untrack_reader, tx_key}) end end @doc """ Retrieves the value associated with a key. Returns the default value if the key is not found. ## Parameters - `db` - The database server (PID or registered name) - `key` - The key to look up - `opts` - A keyword list with the following options (default: `[]`): - `:tag` - Tag the key is namespaced under - `:default` - Value to return if `key` is not found (default: `nil`) ## Returns - The value associated with the key, or `default` if not found ## Examples Goblin.get(db, :alice) # => "Alice" Goblin.get(db, :nonexistent) # => nil Goblin.get(db, :nonexistent, default: :not_found) # => :not_found Goblin.get(db, :alice, tag: :admins) # => "Alice" """ def get(db, key, opts \\ []) do read(db, fn tx -> Tx.get(tx, key, opts) end) end @doc """ Retrieves values for multiple keys in a single read. Keys not found in the database are excluded from the result. ## Parameters - `db` - The database server (PID or registered name) - `keys` - A list of keys to look up - `opts` - A keyword list with the following options (default: `[]`): - `:tag` - Tag the keys are namespaced under ## Returns - A list of `{key, value}` tuples for keys found, in unspecified order ## Examples Goblin.get_multi(db, [:alice, :bob]) # => [{:alice, "Alice"}, {:bob, "Bob"}] Goblin.get_multi(db, [:alice, :nonexistent]) # => [{:alice, "Alice"}] """ def get_multi(db, keys, opts \\ []) do read(db, fn tx -> Tx.get_multi(tx, keys, opts) end) end @doc """ Returns a stream of key-value pairs, optionally bounded by a range. Captures snapshots at enumeration. Entries are sorted by key in ascending order. Both `min` and `max` are inclusive. ## Parameters - `db` - The database server (PID or registered name) - `opts` - Keyword list of options: - `:min` - Minimum key, inclusive (optional) - `:max` - Maximum key, inclusive (optional) - `:tag` - Tag to filter by (optional) ## Returns - A stream of `{key, value}` tuples ## Examples Goblin.scan(db) |> Enum.to_list() # => [{:alice, "Alice"}, {:bob, "Bob"}, {:charlie, "Charlie"}] Goblin.scan(db, min: :bob) |> Enum.to_list() # => [{:bob, "Bob"}, {:charlie, "Charlie"}] Goblin.scan(db, min: :alice, max: :bob) |> Enum.to_list() # => [{:alice, "Alice"}, {:bob, "Bob"}] scan = Goblin.scan(db) Enum.to_list(scan) # => [] Goblin.put(db, :alice, "Alice") Enum.to_list(scan) # => [{:alice, "Alice"}] """ def scan(db, opts \\ []) do mvcc = get_mvcc(db) tx_key = make_ref() Tx.scan( fn -> :gen_statem.cast(db, {:track_reader, self(), tx_key}) {seq, _max_lk, tx_id} = MVCC.add_reader(mvcc, tx_key) {seq, MVCC.get_tables(mvcc, tx_id)} end, Keyword.put(opts, :after, fn -> MVCC.release_reader(mvcc, tx_key) :gen_statem.cast(db, {:untrack_reader, tx_key}) end) ) end @doc """ Exports a snapshot of the database as a `.tar.gz` archive. The archive can be unpacked and used as the `data_dir` for a new database instance, acting as a backup. The export is run inside the server, thus blocking file deletion and writes until completed. ## Parameters - `db` - The database server (PID or registered name) - `export_dir` - Directory to place the exported `.tar.gz` file ## Returns - `{:ok, export_path}` - Path to the created archive - `{:error, reason}` - If an error occurred ## Examples Goblin.export(db, "/backups") # => {:ok, "/backups/goblin_20260220T120000Z.tar.gz"} """ @spec export(:gen_statem.server_ref(), Path.t(), keyword()) :: {:ok, Path.t()} | {:error, term()} def export(db, export_dir, opts \\ []) do :gen_statem.call(db, {:export, export_dir}, opts[:timeout] || @default_timeout) end @doc """ Returns whether a memory-to-disk flush is currently running. """ @spec flushing?(:gen_statem.server_ref(), keyword()) :: boolean() def flushing?(db, opts \\ []) do :gen_statem.call(db, :flushing?, opts[:timeout] || @default_timeout) end @doc """ Returns whether background compaction is currently running. """ @spec compacting?(:gen_statem.server_ref(), keyword()) :: boolean() def compacting?(db, opts \\ []) do :gen_statem.call(db, :compacting?, opts[:timeout] || @default_timeout) end @doc """ Starts the database. Creates the `data_dir` if it does not exist. ## Options - `:name` - Registered name for the database (optional, defaults to `Goblin`) - `:data_dir` - Directory path for database files (required) - `:mem_limit` - Bytes to buffer in memory before flushing to disk (default: 64 MB) - `:bf_fpp` - Bloom filter false positive probability (default: 0.01) ## Returns - `{:ok, pid}` - On successful start - `{:error, reason}` - On failure ## Examples {:ok, db} = Goblin.start_link( name: MyApp.DB, data_dir: "/var/lib/myapp/db" ) """ def start_link(opts) do opts = Keyword.put_new(opts, :name, __MODULE__) :gen_statem.start_link({:local, opts[:name]}, __MODULE__, opts, []) end @doc """ Starts the database, see `start_link/1` for more details. """ def start(opts) do opts = Keyword.put_new(opts, :name, __MODULE__) :gen_statem.start({:local, opts[:name]}, __MODULE__, opts, []) end @doc """ Stops the database. """ @spec stop(:gen_statem.server_ref(), term(), timeout()) :: :ok def stop(db, reason \\ :normal, timeout \\ :infinity) do :gen_statem.stop(db, reason, timeout) end @spec child_spec(keyword()) :: Supervisor.child_spec() def child_spec(opts) do %{ id: opts[:name] || __MODULE__, start: {__MODULE__, :start_link, [opts]}, type: :worker } end @impl :gen_statem def callback_mode, do: :state_functions @impl :gen_statem def terminate(_reason, _state, db) do db.manifest && Manifest.close(db.manifest) db.mem_table && MemTable.close(db.mem_table) :ok end @impl :gen_statem def init(args) do data_dir = args[:data_dir] || raise ":data_dir required" file_counter = :atomics.new(1, signed: false) File.exists?(data_dir) || File.mkdir_p!(data_dir) opts = args |> Keyword.put_new(:bit_array_size, @default_bit_array_size) |> Keyword.put_new(:fpp, @default_fpp) |> Keyword.put_new(:mem_limit, @default_mem_limit) |> Keyword.put_new(:flush_level_file_limit, @default_flush_level_file_limit) |> Keyword.put_new(:level_base_size, @default_level_base_size) |> Keyword.put_new(:level_size_multiplier, @default_level_size_multiplier) |> Keyword.put_new( :max_sst_size, div(@default_level_base_size, @default_level_size_multiplier) ) case Manifest.open(data_dir) do {:ok, manifest} -> {:ok, :idle, %__MODULE__{ data_dir: data_dir, file_counter: file_counter, manifest: manifest, mvcc: MVCC.new(), opts: opts }, {:next_event, :internal, :restore_db}} {:error, reason} -> {:stop, reason} end end def idle(:internal, :restore_db, db) do {manifest_seq, files, dirt} = Manifest.snapshot(db.manifest) max_count = files |> Enum.map(&get_count_from_file/1) |> Enum.max(fn -> 0 end) :atomics.put(db.file_counter, 1, max_count + 1) db = %{db | sequence: manifest_seq} with {:ok, dirt} <- cleanup(dirt), {:ok, manifest} <- Manifest.sweep_dirt(db.manifest, dirt), {:ok, db} <- handle_restore(%{db | manifest: manifest}, files) do publish_snapshot(db) mark_ready(self(), db.mvcc) {:keep_state, db} else {:error, reason} -> {:stop, reason} end end def idle({:call, {pid, _} = from}, {:start_tx, tx_key}, db) do monitor_ref = Process.monitor(pid) writer = {tx_key, monitor_ref, from} {:next_state, :occupied, %{db | writer: writer}, [{:reply, from, :ok}]} end def idle({:call, from}, {:export, export_dir}, db) do reply = handle_export(db, export_dir) {:keep_state, db, [{:reply, from, reply}]} end def idle({:call, from}, :flushing?, db) do {:keep_state, db, [{:reply, from, db_flushing?(db)}]} end def idle({:call, from}, :compacting?, db) do {:keep_state, db, [{:reply, from, db_compacting?(db)}]} end def idle(:cast, {:track_reader, pid, tx_key}, db) do {:keep_state, handle_track_reader(db, pid, tx_key)} end def idle(:cast, {:untrack_reader, tx_key}, db) do {:next_state, :sweeping, handle_untrack_reader(db, tx_key), [{:next_event, :internal, :sweep}]} end def idle(:info, {ref, merge_result}, db) do case handle_merge(db, ref, merge_result) do {:ok, db} -> {:next_state, :sweeping, db, [{:next_event, :internal, :sweep}]} {:error, reason} -> {:stop, reason, db} end end def idle(:info, {:DOWN, _, _, _, _} = down, db) do case handle_down(db, down) do {:ok, db} -> {:next_state, :sweeping, db, [{:next_event, :internal, :sweep}]} {:error, reason} -> {:stop, reason, db} end end def occupied(:internal, :next_writer, db) do case :queue.out(db.writer_queue) do {:empty, _} -> {:next_state, :sweeping, db, [{:next_event, :internal, :sweep}]} {{:value, {_, _, from} = writer}, writer_queue} -> db = %{db | writer: writer, writer_queue: writer_queue} {:keep_state, db, [{:reply, from, :ok}]} end end def occupied(:internal, :flush, db) do case maybe_flush(db) do {:ok, db} -> {:keep_state, db, [{:next_event, :internal, :next_writer}]} {:error, reason} -> {:stop, reason, db} end end def occupied({:call, {pid, _} = from}, {:start_tx, tx_key}, db) do monitor_ref = Process.monitor(pid) writer = {tx_key, monitor_ref, from} writer_queue = :queue.in(writer, db.writer_queue) {:keep_state, %{db | writer_queue: writer_queue}} end def occupied({:call, from}, {:commit_tx, tx}, db) do new_seq = tx.sequence {_, monitor_ref, _} = db.writer Process.demonitor(monitor_ref) commits = Enum.reverse(tx.commits) with :ok <- MemTable.append(db.mem_table, commits) do db = %{db | sequence: new_seq, writer: nil} publish_snapshot(db) {:keep_state, db, [{:reply, from, :ok}, {:next_event, :internal, :flush}]} else {:error, reason} = error -> {:stop_and_reply, reason, db, [{:reply, from, error}]} end end def occupied({:call, from}, {:cancel_tx, tx_key}, %{writer: {tx_key, _, _} = writer} = db) do {_, monitor_ref, _} = writer Process.demonitor(monitor_ref) {:keep_state, %{db | writer: nil}, [{:reply, from, :ok}, {:next_event, :internal, :next_writer}]} end def occupied({:call, from}, {:cancel_tx, _}, db) do {:keep_state, db, [{:reply, from, {:error, :not_writer}}]} end def occupied({:call, from}, {:export, export_dir}, db) do reply = handle_export(db, export_dir) {:keep_state, db, [{:reply, from, reply}]} end def occupied({:call, from}, :flushing?, db) do {:keep_state, db, [{:reply, from, db_flushing?(db)}]} end def occupied({:call, from}, :compacting?, db) do {:keep_state, db, [{:reply, from, db_compacting?(db)}]} end def occupied(:cast, {:track_reader, pid, tx_key}, db) do {:keep_state, handle_track_reader(db, pid, tx_key)} end def occupied(:cast, {:untrack_reader, tx_key}, db) do {:keep_state, handle_untrack_reader(db, tx_key)} end def occupied(:info, {ref, merge_result}, db) do case handle_merge(db, ref, merge_result) do {:ok, db} -> {:keep_state, db} {:error, reason} -> {:stop, reason, db} end end def occupied(:info, {:DOWN, _, _, _, _} = down, db) do case handle_down(db, down) do {:ok, %{writer: nil} = db} -> {:keep_state, db, [{:next_event, :internal, :next_writer}]} {:ok, db} -> {:keep_state, db} {:error, reason} -> {:stop, reason, db} end end def sweeping(:internal, :sweep, db) do case MVCC.sweep(db.mvcc) do [] -> {:next_state, :idle, db} to_sweep -> with {:ok, paths} <- cleanup(to_sweep), {:ok, manifest} <- Manifest.sweep_dirt(db.manifest, paths) do {:next_state, :idle, %{db | manifest: manifest}} else {:error, reason} -> {:stop, reason, db} end end end defp handle_merge(db, ref, {:ok, new, old}) do if Map.has_key?(db.flushing, ref) or Map.has_key?(db.compacting, ref) do flushing = Map.delete(db.flushing, ref) compacting = Map.delete(db.compacting, ref) with {:ok, manifest} <- Manifest.update(db.manifest, tag(new), tag(old), db.sequence) do levels = Enum.reduce(new, db.levels, &Levels.put(&2, &1)) db = %{ db | levels: levels, manifest: manifest, flushing: flushing, compacting: compacting } publish_snapshot(db) {:ok, compact(db)} end else {:ok, db} end end defp handle_merge(db, ref, {:error, reason}) do if Map.has_key?(db.flushing, ref) or Map.has_key?(db.compacting, ref), do: {:error, reason}, else: {:ok, db} end defp handle_export(db, export_dir) do {_, files, _} = Manifest.snapshot(db.manifest) files = Enum.map(files, &elem(&1, 1)) manifest_file = Manifest.current_file(db.manifest) Export.into_tar(export_dir, [manifest_file | files]) end defp handle_down(db, {_, ref, _, _, reason}) do cond do match?({_, ^ref, _}, db.writer) -> {tx_key, _, _} = db.writer MVCC.release_reader(db.mvcc, tx_key) {:ok, %{db | writer: nil}} Map.has_key?(db.flushing, ref) -> {:error, reason} Map.has_key?(db.compacting, ref) -> {:error, reason} Map.has_key?(db.readers, ref) -> {tx_key, readers} = Map.pop(db.readers, ref) MVCC.release_reader(db.mvcc, tx_key) {:ok, %{db | readers: readers}} true -> {out, writer_queue} = :queue.fold( fn {_, ^ref, _} = queued_writer, {_, acc} -> {queued_writer, acc} queued_writer, {out, acc} -> {out, :queue.in(queued_writer, acc)} end, {nil, :queue.new()}, db.writer_queue ) case out do nil -> {:ok, db} {tx_key, _, _} -> MVCC.release_reader(db.mvcc, tx_key) {:ok, %{db | writer_queue: writer_queue}} end end end defp handle_track_reader(db, pid, tx_key) do monitor_ref = Process.monitor(pid) readers = Map.put(db.readers, monitor_ref, tx_key) %{db | readers: readers} end defp handle_untrack_reader(db, tx_key) do monitor_ref = Enum.find_value(db.readers, fn {monitor_ref, ^tx_key} -> monitor_ref _ -> false end) case monitor_ref do nil -> db monitor_ref -> Process.demonitor(monitor_ref) readers = Map.delete(db.readers, monitor_ref) %{db | readers: readers} end end defp publish_snapshot(db) do mem_tables = [db.mem_table | Map.values(db.flushing)] levels = db.compacting |> Map.values() |> List.flatten() |> Enum.reduce(db.levels, &Levels.put(&2, &1)) MVCC.put_snapshot(db.mvcc, mem_tables, levels, db.sequence) end defp handle_restore(db, []) do if is_nil(db.mem_table) do with {:ok, db} <- flush(db), {:ok, manifest} <- Manifest.update(db.manifest, tag([db.mem_table]), [], db.sequence) do {:ok, %{db | manifest: manifest}} end else with {:ok, db} <- maybe_flush(db) do {:ok, compact(db)} end end end defp handle_restore(db, [{:mem, file} | files]) do with {:ok, db} <- flush(db, file) do handle_restore(db, files) end end defp handle_restore(db, [{:disk, file} | files]) do with {:ok, dt} <- DiskTable.from_file(file) do levels = Levels.put(db.levels, dt) db = %{db | levels: levels} handle_restore(db, files) end end defp maybe_flush(db) do if MemTable.size(db.mem_table) >= db.opts[:mem_limit] do with {:ok, db} <- flush(db), {:ok, manifest} <- Manifest.update(db.manifest, tag([db.mem_table]), [], db.sequence) do db = %{db | manifest: manifest} publish_snapshot(db) {:ok, db} end else {:ok, db} end end defp flush(db, file \\ nil) do file = file || gen_file(db.file_counter, db.data_dir, @wal_suffix) with {:ok, seq, new_mt} <- MemTable.new(file), :ok <- close_wal(db.mem_table) do db = case db.mem_table do nil -> db mt -> merge(db, 0, [mt]) end {:ok, %{db | mem_table: new_mt, sequence: max(db.sequence, seq)}} end end defp compact(%{compacting: compacting} = db) when map_size(compacting) == 0 do case Levels.next(db.levels, db.opts) do nil -> db {:merge, lk, dts, filter_tombstones?, levels} -> %{db | levels: levels} |> merge(lk, dts, filter_tombstones?) end end defp compact(db), do: db defp merge(db, lk, tables, filter_tombstones? \\ false) do opts = [ level_key: lk, compress?: lk > 1, bit_array_size: db.opts[:bit_array_size], max_size: db.opts[:max_sst_size], fpp: db.opts[:fpp], filer: fn -> gen_file(db.file_counter, db.data_dir) end, filter_tombstones?: filter_tombstones? ] run_merge(db, tables, opts) end defp run_merge(db, [%MemTable{} = mt], opts) do %{ref: ref} = Task.async(fn -> stream = MemTable.stream(mt) with {:ok, dts} <- DiskTable.build(stream, opts) do {:ok, dts, [mt]} end end) flushing = Map.put(db.flushing, ref, mt) %{db | flushing: flushing} end defp run_merge(db, dts, opts) do %{ref: ref} = Task.async(fn -> stream = Merge.stream( fn -> Enum.map(dts, &DiskTable.stream/1) end, filter_tombstones?: opts[:filter_tombstones?] ) with {:ok, new_dts} <- DiskTable.build(stream, opts) do {:ok, new_dts, dts} end end) compacting = Map.put(db.compacting, ref, dts) %{db | compacting: compacting} end defp start_transaction(db, tx_key, opts), do: :gen_statem.call(db, {:start_tx, tx_key}, opts[:timeout] || @default_timeout) defp commit_transaction(db, tx, opts), do: :gen_statem.call(db, {:commit_tx, tx}, opts[:timeout] || @default_timeout) defp cancel_transaction(db, tx_key, opts), do: :gen_statem.call(db, {:cancel_tx, tx_key}, opts[:timeout] || @default_timeout) defp db_flushing?(db), do: map_size(db.flushing) != 0 defp db_compacting?(db), do: map_size(db.compacting) != 0 defp close_wal(nil), do: :ok defp close_wal(mt), do: MemTable.close(mt) defp cleanup(tables, acc \\ []) defp cleanup([], acc), do: {:ok, acc} defp cleanup([%MemTable{} = mt | rest], acc) do with :ok <- FileIO.remove(mt.id) do MemTable.destroy(mt) cleanup(rest, [mt.id | acc]) end end defp cleanup([%DiskTable{} = dt | rest], acc) do with :ok <- FileIO.remove(dt.id) do cleanup(rest, [dt.id | acc]) end end defp cleanup([path | rest], acc) do with :ok <- FileIO.remove(path) do cleanup(rest, [path | acc]) end end defp gen_file(ref, dir, suffix \\ @goblin_suffix) do prefix = (:atomics.add_get(ref, 1, 1) - 1) |> Integer.to_string(16) |> String.pad_leading(20, "0") path = Path.join(dir, "#{prefix}.#{suffix}") if File.exists?(path), do: File.rm!(path) path end defp get_count_from_file({type, path}) do suffix = case type do :mem -> @wal_suffix :disk -> @goblin_suffix end path |> Path.basename(".#{suffix}") |> String.to_integer(16) end defp tag([]), do: [] defp tag([table | tables]), do: [tag(table) | tag(tables)] defp tag(%MemTable{} = mt), do: {:mem, mt.id} defp tag(%DiskTable{} = dt), do: {:disk, dt.id} defp mark_ready(db, ref), do: :persistent_term.put({__MODULE__, db}, ref) defp get_mvcc(db, timeout \\ 20_000) defp get_mvcc(_, timeout) when timeout <= 0, do: raise("db not ready") defp get_mvcc(db, timeout) do db = if is_pid(db), do: db, else: Process.whereis(db) case :persistent_term.get({__MODULE__, db}, nil) do nil -> Process.sleep(50) get_mvcc(db, timeout - 50) mvcc -> mvcc end end end