Add hybrid mnesia_rocksdb backup and restore #12
@@ -43,7 +43,7 @@ is provided.
|
||||
|
||||
* sext (included as dependency)
|
||||
|
||||
* Erlang/OTP 21.0 or newer (https://github.com/erlang/otp)
|
||||
* Erlang/OTP 27.0 or newer (https://github.com/erlang/otp)
|
||||
|
||||
|
||||
|
||||
@@ -115,6 +115,65 @@ source code in `mnesia_rocksdb_tuning.erl` and `mnesia_rocksdb_params.erl`.
|
||||
Contributions are welcome.
|
||||
|
||||
|
||||
#### <a name="Backup">Backup</a> ####
|
||||
|
||||
`mnesia_rocksdb:backup(Dir)` creates a **local hybrid backup** of Mnesia
|
||||
metadata/tables and RocksDB data. Unlike `mnesia:backup/1`, RocksDB-backed
|
||||
tables are not dumped record-by-record through Mnesia; they are captured with
|
||||
the [RocksDB backup engine](https://rocksdb.hexdocs.pm/1.9.0/how_to_backup_rocksdb.html).
|
||||
|
||||
Layout of `Dir` after a successful backup:
|
||||
|
||||
```
|
||||
Dir/
|
||||
MANIFEST % restore metadata (Erlang term)
|
||||
mnesia.BUP % schema + non-RocksDB tables
|
||||
rocksdb/<alias>/admin/ % backup engine dir for each alias admin DB
|
||||
rocksdb/standalone/... % one dir per open standalone RocksDB table
|
||||
```
|
||||
|
||||
Algorithm (local node only):
|
||||
|
||||
1. Activate a Mnesia checkpoint on **schema and non-RocksDB tables only**
|
||||
(so large RocksDB tables do not get expensive checkpoint retainers).
|
||||
2. For each open RocksDB instance (admin DB per alias, and standalone tables),
|
||||
run `rocksdb:create_new_backup/2`.
|
||||
3. Dump the checkpoint with `mnesia:backup_checkpoint/3`, excluding RocksDB
|
||||
table data. Schema still describes RocksDB tables.
|
||||
4. Write `MANIFEST`.
|
||||
|
||||
Options map (second argument to `backup/2`):
|
||||
|
||||
* `{verify, true}` — call `rocksdb:verify_backup/2` after each RocksDB backup
|
||||
* `{mnesia_backup_module, Mod}` — override the Mnesia backup media module
|
||||
|
||||
#### Restore
|
||||
|
||||
`mnesia_rocksdb:restore(Dir)` restores a hybrid backup offline (local node).
|
||||
|
||||
Mnesia applies fallbacks by **purging** the Mnesia directory. RocksDB data is
|
||||
therefore restored first into a **sibling staging directory** and mountpoints
|
||||
are redirected until the fallback has finished:
|
||||
|
||||
1. Stop Mnesia (closes RocksDB handles).
|
||||
2. Restore each RocksDB DB into
|
||||
`"<basename(mnesia_dir)>.rocksdb_restore/"` (sibling of the Mnesia dir).
|
||||
3. Set application env `mnesia_rocksdb.restore_mountpoint_root` so
|
||||
`data_mountpoint/1` opens DBs from staging.
|
||||
4. Install `Dir/mnesia.BUP` and start Mnesia (fallback purges the real Mnesia
|
||||
dir; RocksDB tables load from staging with full data). During this window,
|
||||
`delete_table` does not destroy staged RocksDB files.
|
||||
5. Stop Mnesia, clear the redirect, move staged `*.extrdb` directories into the
|
||||
real Mnesia directory.
|
||||
6. Optionally start Mnesia (`#{start => true}`).
|
||||
|
||||
Common options for `restore/2`:
|
||||
|
||||
* `{stop, true|false}` — stop Mnesia before restore (default `true`)
|
||||
* `{start, true|false}` — start Mnesia after finalize (default `false`)
|
||||
* `{mnesia_dir, Dir}` — override target Mnesia directory
|
||||
* `{skip_rocksdb, true}` / `{skip_mnesia, true}` — partial restore
|
||||
|
||||
#### <a name="Caveats">Caveats</a> ####
|
||||
|
||||
Avoid placing `bag` tables in RocksDB. Although they work, each write
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ is provided.
|
||||
|
||||
* sext (included as dependency)
|
||||
|
||||
* Erlang/OTP 21.0 or newer (https://github.com/erlang/otp)
|
||||
* Erlang/OTP 27.0 or newer (https://github.com/erlang/otp)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ backs up data.
|
||||
|
||||
- rocksdb (included as dependency)
|
||||
- sext (included as dependency)
|
||||
- Erlang/OTP 22.0 or newer (https://github.com/erlang/otp)
|
||||
- Erlang/OTP 27.0 or newer (https://github.com/erlang/otp)
|
||||
|
||||
### Getting started
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
%% -*- erlang -*-
|
||||
{minimum_otp_vsn, "21.0"}.
|
||||
{minimum_otp_vsn, "27.0"}.
|
||||
|
||||
{deps,
|
||||
[
|
||||
|
||||
+43
-1
@@ -49,7 +49,11 @@
|
||||
|
||||
-export([register/0,
|
||||
register/1,
|
||||
default_alias/0]).
|
||||
default_alias/0,
|
||||
backup/1,
|
||||
backup/2,
|
||||
restore/1,
|
||||
restore/2]).
|
||||
|
||||
%%
|
||||
%% DEBUG API
|
||||
@@ -218,6 +222,44 @@ get_ref(Tab) ->
|
||||
default_alias() ->
|
||||
rocksdb_copies.
|
||||
|
||||
%% @doc Hybrid local backup of Mnesia + RocksDB tables.
|
||||
%%
|
||||
%% Similar intent to `mnesia:backup/1`, but RocksDB-backed tables are backed up
|
||||
%% with the RocksDB backup engine instead of record-level iteration.
|
||||
%%
|
||||
%% `Dir` must be a non-existent or empty directory. On success it contains:
|
||||
%% - `MANIFEST` — restore metadata
|
||||
%% - `mnesia.BUP` — schema and non-RocksDB tables
|
||||
%% - `rocksdb/...` — native RocksDB backups for each open DB
|
||||
%%
|
||||
%% Local node only. See `mnesia_rocksdb_admin:backup/2` for details and options.
|
||||
%% @end
|
||||
-spec backup(file:filename_all()) -> ok | {error, term()}.
|
||||
backup(Dir) ->
|
||||
mnesia_rocksdb_admin:backup(Dir, #{}).
|
||||
|
||||
%% @equiv mnesia_rocksdb_admin:backup/2
|
||||
-spec backup(file:filename_all(), map()) -> ok | {error, term()}.
|
||||
backup(Dir, Opts) when is_map(Opts) ->
|
||||
mnesia_rocksdb_admin:backup(Dir, Opts).
|
||||
|
||||
%% @doc Restore a hybrid backup created by {@link backup/1}.
|
||||
%%
|
||||
%% Offline restore: RocksDB data is restored into a sibling staging directory
|
||||
%% first; Mnesia fallback runs with mountpoints redirected there (so the
|
||||
%% fallback purge does not wipe RocksDB data); then staged dirs are moved into
|
||||
%% the real Mnesia directory. Optionally starts Mnesia (`#{start => true}`).
|
||||
%%
|
||||
%% See `mnesia_rocksdb_admin:restore/2` for options and the full sequence.
|
||||
%% @end
|
||||
-spec restore(file:filename_all()) -> ok | {error, term()}.
|
||||
restore(Dir) ->
|
||||
mnesia_rocksdb_admin:restore(Dir, #{}).
|
||||
|
||||
%% @equiv mnesia_rocksdb_admin:restore/2
|
||||
-spec restore(file:filename_all(), map()) -> ok | {error, term()}.
|
||||
restore(Dir, Opts) when is_map(Opts) ->
|
||||
mnesia_rocksdb_admin:restore(Dir, Opts).
|
||||
|
||||
encode_key(Key) ->
|
||||
mnesia_rocksdb_lib:encode_key(Key, sext).
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
, request_ref/2 %% (Alias, Name) -> {ok, Ref} | {error, _}
|
||||
, close_table/2
|
||||
, clear_table/1
|
||||
, backup/1 %% (Dir) -> ok | {error, _}
|
||||
, backup/2 %% (Dir, Opts) -> ok | {error, _}
|
||||
, restore/1 %% (Dir) -> ok | {error, _}
|
||||
, restore/2 %% (Dir, Opts) -> ok | {error, _}
|
||||
, list_open_dbs/0 %% () -> [db_backup_spec()]
|
||||
]).
|
||||
|
||||
-export([ migrate_standalone/2
|
||||
@@ -109,7 +114,35 @@
|
||||
| {abort_migration, tabname()}
|
||||
| {prep_close, table()}
|
||||
| {close_table, table()}
|
||||
| {clear_table, table() | cf() }.
|
||||
| {clear_table, table() | cf() }
|
||||
| list_open_dbs.
|
||||
|
||||
%% Spec describing an open RocksDB instance eligible for backup.
|
||||
-type db_backup_spec() :: #{ kind := admin | standalone
|
||||
, alias := alias()
|
||||
, name := table()
|
||||
, db_ref := db_ref()
|
||||
, mountpoint := file:filename()
|
||||
}.
|
||||
|
||||
-type backup_opts() :: #{ verify => boolean()
|
||||
, mnesia_backup_module => module()
|
||||
}.
|
||||
|
||||
%% Restore options:
|
||||
%% - `mnesia_dir': target Mnesia directory (default: configured Mnesia dir)
|
||||
%% - `stop': stop Mnesia if running before restore (default true)
|
||||
%% - `start': start Mnesia after restore (default false)
|
||||
%% - `mnesia_backup_module': module for reading mnesia.BUP
|
||||
%% - `skip_rocksdb': only install Mnesia fallback (default false)
|
||||
%% - `skip_mnesia': only restore RocksDB DBs (default false)
|
||||
-type restore_opts() :: #{ mnesia_dir => file:filename_all()
|
||||
, stop => boolean()
|
||||
, start => boolean()
|
||||
, mnesia_backup_module => module()
|
||||
, skip_rocksdb => boolean()
|
||||
, skip_mnesia => boolean()
|
||||
}.
|
||||
|
||||
-type reason() :: any().
|
||||
-type reply() :: any().
|
||||
@@ -164,7 +197,13 @@ erase_pt_list(Names) ->
|
||||
|
||||
check_application_defaults(Meta) ->
|
||||
Value = application:get_env(mnesia_rocksdb, mnesia_compatible_aborts, false),
|
||||
Meta#{ {mnesia_compatible_aborts} => Value }.
|
||||
Meta1 = Meta#{ {mnesia_compatible_aborts} => Value },
|
||||
case application:get_env(mnesia_rocksdb, restore_mountpoint_root) of
|
||||
{ok, Root} when is_list(Root); is_binary(Root) ->
|
||||
Meta1#{ {restore_mountpoint_root} => filename:absname(Root) };
|
||||
_ ->
|
||||
Meta1
|
||||
end.
|
||||
|
||||
get_cached_env(Key, Default) ->
|
||||
maps:get({Key}, meta(), Default).
|
||||
@@ -200,6 +239,96 @@ get_pt(Name, Default) ->
|
||||
set_and_cache_env(Key, Value) ->
|
||||
gen_server:call(?MODULE, {set_and_cache_env, Key, Value}).
|
||||
|
||||
%% @doc Hybrid backup of Mnesia metadata/non-RocksDB tables and RocksDB DBs.
|
||||
%%
|
||||
%% Creates a directory layout:
|
||||
%% ```
|
||||
%% Dir/
|
||||
%% MANIFEST
|
||||
%% mnesia.BUP
|
||||
%% rocksdb/<alias>/admin/
|
||||
%% rocksdb/standalone/<name>/
|
||||
%% '''
|
||||
%%
|
||||
%% Steps:
|
||||
%% 1. Activate a Mnesia checkpoint covering schema and non-RocksDB tables only
|
||||
%% (avoids heavy RocksDB retainer tables).
|
||||
%% 2. For each open RocksDB instance (admin DBs and standalone tables), create a
|
||||
%% RocksDB-native backup via the backup engine.
|
||||
%% 3. Dump the checkpoint with `mnesia:backup_checkpoint/3`, excluding RocksDB
|
||||
%% table data (schema still describes those tables).
|
||||
%% 4. Write a MANIFEST describing the layout for a future restore.
|
||||
%%
|
||||
%% Local-node only. Cross-store consistency is best-effort within the checkpoint
|
||||
%% window.
|
||||
%%
|
||||
%% Options:
|
||||
%% - `verify` (boolean, default false): call `rocksdb:verify_backup/2`
|
||||
%% - `mnesia_backup_module` (module): override Mnesia backup media module
|
||||
-spec backup(file:filename_all()) -> ok | {error, term()}.
|
||||
backup(Dir) ->
|
||||
backup(Dir, #{}).
|
||||
|
||||
-spec backup(file:filename_all(), backup_opts()) -> ok | {error, term()}.
|
||||
backup(Dir, Opts) when is_map(Opts) ->
|
||||
ensure_started(),
|
||||
case mnesia:system_info(is_running) of
|
||||
yes ->
|
||||
case prepare_backup_dir(Dir) of
|
||||
ok ->
|
||||
try do_backup(filename:absname(Dir), Opts)
|
||||
catch
|
||||
error:Reason:Stack ->
|
||||
{error, {Reason, Stack}};
|
||||
throw:{error, _} = Err ->
|
||||
Err
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
Other ->
|
||||
{error, {mnesia_not_running, Other}}
|
||||
end.
|
||||
|
||||
%% @doc Restore a hybrid backup previously created by {@link backup/1}.
|
||||
%%
|
||||
%% Offline restore (local node). Mnesia fallback application **purges** the
|
||||
%% Mnesia directory, so RocksDB data is restored first into a **sibling**
|
||||
%% staging directory and mountpoints are redirected while the fallback runs:
|
||||
%%
|
||||
%% 1. Stop Mnesia (closes RocksDB handles).
|
||||
%% 2. Restore each RocksDB DB into
|
||||
%% `"<mnesia_dir_basename>.rocksdb_restore/"` (sibling of the Mnesia dir).
|
||||
%% 3. Set `restore_mountpoint_root` so `data_mountpoint/1` uses the staging
|
||||
%% location.
|
||||
%% 4. Install `mnesia.BUP` and start Mnesia (fallback purges the real Mnesia
|
||||
%% dir; RocksDB tables open from staging with full data).
|
||||
%% 5. Stop Mnesia, clear the redirect, move staged `*.extrdb` into the real
|
||||
%% Mnesia directory.
|
||||
%% 6. Optionally start Mnesia (`#{start => true}`).
|
||||
-spec restore(file:filename_all()) -> ok | {error, term()}.
|
||||
restore(Dir) ->
|
||||
restore(Dir, #{}).
|
||||
|
||||
-spec restore(file:filename_all(), restore_opts()) -> ok | {error, term()}.
|
||||
restore(Dir, Opts) when is_map(Opts) ->
|
||||
try do_restore(filename:absname(Dir), Opts)
|
||||
catch
|
||||
error:Reason:Stack ->
|
||||
{error, {Reason, Stack}};
|
||||
throw:{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
%% @doc Snapshot of open RocksDB instances (admin + standalone).
|
||||
-spec list_open_dbs() -> [db_backup_spec()].
|
||||
list_open_dbs() ->
|
||||
ensure_started(),
|
||||
case gen_server:call(?MODULE, {[], list_open_dbs}, infinity) of
|
||||
{ok, Dbs} -> Dbs;
|
||||
{error, _} = E -> error(E)
|
||||
end.
|
||||
|
||||
create_table(Alias, Name, Props) ->
|
||||
call(Alias, {create_table, Name, Props}).
|
||||
|
||||
@@ -616,6 +745,8 @@ handle_call({[], {get_ref, Name}}, _From, St) ->
|
||||
_ ->
|
||||
{reply, {error, not_found}, St}
|
||||
end;
|
||||
handle_call({[], list_open_dbs}, _From, St) ->
|
||||
{reply, {ok, list_open_dbs_from_st(St)}, St};
|
||||
handle_call({Alias, Req}, _From, St) ->
|
||||
handle_call_for_alias(Alias, Req, St);
|
||||
handle_call(_Req, _From, St) ->
|
||||
@@ -1604,11 +1735,19 @@ do_close_table(Alias, Name, Backend, #st{standalone = Ts} = St) ->
|
||||
end.
|
||||
|
||||
do_delete_table(Alias, Name, Backend, #st{} = St) ->
|
||||
case restore_staging_active() of
|
||||
true ->
|
||||
%% Hybrid restore: RocksDB lives in a staging sibling. Mnesia
|
||||
%% fallback calls delete_table before reopening; must not destroy
|
||||
%% staged files or drop CFs that still hold restored data.
|
||||
{ok, St};
|
||||
false ->
|
||||
case find_cf(Alias, Name, Backend, St) of
|
||||
{ok, Where} ->
|
||||
erase_pt(Name),
|
||||
case Where of
|
||||
#{db_ref := DbRef, cf_handle := CfH, type := column_family} ->
|
||||
#{db_ref := DbRef, cf_handle := CfH,
|
||||
type := column_family} ->
|
||||
rocksdb:drop_column_family(DbRef, CfH),
|
||||
drop_cached_cf(tab_to_cf_name(Name), CfH),
|
||||
rocksdb:destroy_column_family(DbRef, CfH),
|
||||
@@ -1619,8 +1758,12 @@ do_delete_table(Alias, Name, Backend, #st{} = St) ->
|
||||
end;
|
||||
error ->
|
||||
{error, not_found}
|
||||
end
|
||||
end.
|
||||
|
||||
restore_staging_active() ->
|
||||
mnesia_rocksdb_lib:restore_mountpoint_root() =/= undefined.
|
||||
|
||||
load_admin_db(Alias, Opts) ->
|
||||
DbName = {admin, Alias},
|
||||
open_db(DbName, Alias, Opts, [DbName], true).
|
||||
@@ -2377,3 +2520,669 @@ destroy_db(MPd, Opts, RetriesLeft, _) ->
|
||||
|
||||
get_retries() -> 30.
|
||||
get_retry_delay() -> 10000.
|
||||
|
||||
%% ===================================================================
|
||||
%% Hybrid backup (Mnesia checkpoint + RocksDB backup engine)
|
||||
%% ===================================================================
|
||||
|
||||
list_open_dbs_from_st(#st{backends = Bs, standalone = Ts}) ->
|
||||
Admin =
|
||||
maps:fold(
|
||||
fun(Alias, #{db_ref := DbRef, mountpoint := MP}, Acc) ->
|
||||
[#{ kind => admin
|
||||
, alias => Alias
|
||||
, name => {admin, Alias}
|
||||
, db_ref => DbRef
|
||||
, mountpoint => MP } | Acc];
|
||||
(_, _, Acc) ->
|
||||
Acc
|
||||
end, [], Bs),
|
||||
Standalone =
|
||||
maps:fold(
|
||||
fun({Alias, Name}, #{db_ref := DbRef, mountpoint := MP} = R, Acc) ->
|
||||
case maps:get(status, R, open) of
|
||||
closed ->
|
||||
Acc;
|
||||
_ ->
|
||||
[#{ kind => standalone
|
||||
, alias => Alias
|
||||
, name => Name
|
||||
, db_ref => DbRef
|
||||
, mountpoint => MP } | Acc]
|
||||
end;
|
||||
(_, _, Acc) ->
|
||||
Acc
|
||||
end, [], Ts),
|
||||
Admin ++ Standalone.
|
||||
|
||||
prepare_backup_dir(Dir) ->
|
||||
case filelib:is_dir(Dir) of
|
||||
true ->
|
||||
case file:list_dir(Dir) of
|
||||
{ok, []} ->
|
||||
ok;
|
||||
{ok, _} ->
|
||||
{error, {not_empty, Dir}};
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
false ->
|
||||
case filelib:ensure_dir(filename:join(Dir, "dummy")) of
|
||||
ok ->
|
||||
case file:make_dir(Dir) of
|
||||
ok -> ok;
|
||||
{error, eexist} ->
|
||||
%% Race: created between is_dir and make_dir
|
||||
prepare_backup_dir(Dir);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end
|
||||
end.
|
||||
|
||||
do_backup(Dir, Opts) ->
|
||||
{RdbTabs, NonRdbTabs} = classify_tables(),
|
||||
CpArgs = [ {ram_overrides_dump, false}
|
||||
, {max, NonRdbTabs}
|
||||
, {allow_remote, false} ],
|
||||
case mnesia:activate_checkpoint(CpArgs) of
|
||||
{ok, CpName, _Nodes} ->
|
||||
try
|
||||
Dbs = list_open_dbs(),
|
||||
case backup_rocksdbs(Dir, Dbs, Opts) of
|
||||
{ok, DbInfos} ->
|
||||
Bup = filename:join(Dir, "mnesia.BUP"),
|
||||
case backup_mnesia_checkpoint(CpName, Bup, NonRdbTabs, Opts) of
|
||||
ok ->
|
||||
write_manifest(Dir, NonRdbTabs, RdbTabs, DbInfos);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end
|
||||
after
|
||||
_ = mnesia:deactivate_checkpoint(CpName)
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
classify_tables() ->
|
||||
All = mnesia:system_info(tables),
|
||||
Rdb = [T || T <- All, is_local_rdb_table(T)],
|
||||
NonRdb = All -- Rdb,
|
||||
{lists:sort(Rdb), lists:sort(NonRdb)}.
|
||||
|
||||
is_local_rdb_table(schema) ->
|
||||
false;
|
||||
is_local_rdb_table(Tab) ->
|
||||
try mnesia:table_info(Tab, storage_type) of
|
||||
{ext, _Alias, mnesia_rocksdb} -> true;
|
||||
_ -> false
|
||||
catch
|
||||
_:_ -> false
|
||||
end.
|
||||
|
||||
backup_mnesia_checkpoint(CpName, BupPath, NonRdbTabs, Opts) ->
|
||||
Mod = maps:get(mnesia_backup_module, Opts,
|
||||
mnesia:system_info(backup_module)),
|
||||
%% mnesia:backup_checkpoint/3 is typed as module()-only; the implementation
|
||||
%% (and mnesia_log:backup_checkpoint/3) accepts a proplist with table filter.
|
||||
Args = [ {module, Mod}
|
||||
, {tables, NonRdbTabs}
|
||||
, {scope, local} ],
|
||||
case mnesia_log:backup_checkpoint(CpName, BupPath, Args) of
|
||||
ok -> ok;
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
backup_rocksdbs(Dir, Dbs, Opts) ->
|
||||
backup_rocksdbs(Dir, Dbs, Opts, []).
|
||||
|
||||
backup_rocksdbs(_Dir, [], _Opts, Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
backup_rocksdbs(Dir, [Spec | Rest], Opts, Acc) ->
|
||||
case backup_one_db(Dir, Spec, Opts) of
|
||||
{ok, Info} ->
|
||||
backup_rocksdbs(Dir, Rest, Opts, [Info | Acc]);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
backup_one_db(Dir, #{ db_ref := DbRef
|
||||
, mountpoint := MP
|
||||
, kind := Kind
|
||||
, alias := Alias
|
||||
, name := Name } = Spec, Opts) ->
|
||||
Rel = backup_relpath(Spec),
|
||||
Abs = filename:join(Dir, Rel),
|
||||
case ensure_dir_tree(Abs) of
|
||||
ok ->
|
||||
case rocksdb:open_backup_engine(Abs) of
|
||||
{ok, BE} ->
|
||||
try
|
||||
case rocksdb:create_new_backup(BE, DbRef) of
|
||||
ok ->
|
||||
case latest_backup_id(BE) of
|
||||
{ok, BackupId} ->
|
||||
case maybe_verify_backup(BE, BackupId, Opts) of
|
||||
ok ->
|
||||
{ok, #{ kind => Kind
|
||||
, alias => Alias
|
||||
, name => Name
|
||||
, mountpoint => MP
|
||||
, backup_relpath => Rel
|
||||
, backup_id => BackupId }};
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end
|
||||
after
|
||||
_ = rocksdb:close_backup_engine(BE)
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
backup_relpath(#{kind := admin, alias := Alias}) ->
|
||||
filename:join(["rocksdb", atom_to_list(Alias), "admin"]);
|
||||
backup_relpath(#{kind := standalone, name := Name}) ->
|
||||
filename:join(["rocksdb", "standalone", sanitize_backup_name(Name)]).
|
||||
|
||||
sanitize_backup_name(Name) when is_atom(Name) ->
|
||||
atom_to_list(Name);
|
||||
sanitize_backup_name(Name) ->
|
||||
%% Index/retainer or other compound names: reuse mountpoint basename.
|
||||
filename:basename(mnesia_rocksdb_lib:data_mountpoint(Name), ".extrdb").
|
||||
|
||||
ensure_dir_tree(Dir) ->
|
||||
case filelib:is_dir(Dir) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
case filelib:ensure_dir(filename:join(Dir, "dummy")) of
|
||||
ok ->
|
||||
case file:make_dir(Dir) of
|
||||
ok -> ok;
|
||||
{error, eexist} -> ok;
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end
|
||||
end.
|
||||
|
||||
latest_backup_id(BE) ->
|
||||
%% Erase rocksdb's incomplete -spec (list of #{id}) so we can handle the
|
||||
%% real NIF shape ({ok, [#{backup_id => ...}]}).
|
||||
Raw = binary_to_term(term_to_binary(rocksdb:get_backup_info(BE))),
|
||||
case normalize_backup_infos(Raw) of
|
||||
[] ->
|
||||
{error, no_backup_info};
|
||||
Infos ->
|
||||
{ok, lists:max([backup_info_id(I) || I <- Infos])}
|
||||
end.
|
||||
|
||||
-spec normalize_backup_infos(term()) -> [term()].
|
||||
normalize_backup_infos({ok, Infos}) when is_list(Infos) ->
|
||||
Infos;
|
||||
normalize_backup_infos(Infos) when is_list(Infos) ->
|
||||
Infos;
|
||||
normalize_backup_infos(_) ->
|
||||
[].
|
||||
|
||||
-spec backup_info_id(term()) -> non_neg_integer().
|
||||
backup_info_id(Info) when is_map(Info) ->
|
||||
case maps:find(id, Info) of
|
||||
{ok, Id} when is_integer(Id), Id >= 0 ->
|
||||
Id;
|
||||
error ->
|
||||
case maps:find(backup_id, Info) of
|
||||
{ok, Id} when is_integer(Id), Id >= 0 ->
|
||||
Id;
|
||||
_ ->
|
||||
error({missing_backup_id, Info})
|
||||
end;
|
||||
_ ->
|
||||
error({invalid_backup_id, Info})
|
||||
end;
|
||||
backup_info_id(Other) ->
|
||||
error({invalid_backup_info, Other}).
|
||||
|
||||
maybe_verify_backup(BE, BackupId, Opts) ->
|
||||
case maps:get(verify, Opts, false) of
|
||||
true ->
|
||||
rocksdb:verify_backup(BE, BackupId);
|
||||
false ->
|
||||
ok
|
||||
end.
|
||||
|
||||
write_manifest(Dir, NonRdbTabs, RdbTabs, DbInfos) ->
|
||||
Manifest =
|
||||
#{ version => 1
|
||||
, created => erlang:system_time(second)
|
||||
, node => node()
|
||||
, mnesia_directory => mnesia:system_info(directory)
|
||||
, mnesia_backup => "mnesia.BUP"
|
||||
, mnesia_tables => NonRdbTabs
|
||||
, rocksdb_tables => RdbTabs
|
||||
, dbs => [maps:with([kind, alias, name, mountpoint,
|
||||
backup_relpath, backup_id], I)
|
||||
|| I <- DbInfos]
|
||||
},
|
||||
Path = filename:join(Dir, "MANIFEST"),
|
||||
Bin = unicode:characters_to_binary(io_lib:format("~p.~n", [Manifest])),
|
||||
case file:write_file(Path, Bin) of
|
||||
ok -> ok;
|
||||
{error, _} = E -> E
|
||||
end.
|
||||
|
||||
%% ===================================================================
|
||||
%% Hybrid restore
|
||||
%% ===================================================================
|
||||
|
||||
do_restore(Dir, Opts) ->
|
||||
case read_manifest(Dir) of
|
||||
{ok, Manifest} ->
|
||||
case maybe_stop_mnesia(Opts) of
|
||||
ok ->
|
||||
restore_body(Dir, Manifest, Opts);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
restore_body(Dir, Manifest, Opts) ->
|
||||
SkipMnesia = maps:get(skip_mnesia, Opts, false),
|
||||
SkipRocks = maps:get(skip_rocksdb, Opts, false),
|
||||
MnesiaDir = target_mnesia_dir(Opts, Manifest),
|
||||
case wait_admin_down(100, 50) of
|
||||
ok ->
|
||||
restore_body1(Dir, Manifest, Opts, MnesiaDir,
|
||||
SkipMnesia, SkipRocks);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
restore_body1(Dir, Manifest, Opts, MnesiaDir, SkipMnesia, SkipRocks) ->
|
||||
case {SkipMnesia, SkipRocks} of
|
||||
{true, true} ->
|
||||
ok;
|
||||
{true, false} ->
|
||||
%% RocksDB only: write straight into final mountpoints.
|
||||
case restore_rocksdbs(Dir, Manifest, MnesiaDir) of
|
||||
ok -> maybe_start_mnesia(Opts);
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
{false, true} ->
|
||||
%% Mnesia fallback only (no RocksDB data restore).
|
||||
case install_and_apply_fallback(Dir, Manifest, Opts) of
|
||||
ok -> maybe_start_mnesia(Opts);
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
{false, false} ->
|
||||
staged_full_restore(Dir, Manifest, Opts, MnesiaDir)
|
||||
end.
|
||||
|
||||
%% Full hybrid restore via staging sibling (survives fallback purge).
|
||||
staged_full_restore(Dir, Manifest, Opts, MnesiaDir) ->
|
||||
Staging = mnesia_rocksdb_lib:restore_staging_root(MnesiaDir),
|
||||
try
|
||||
case prepare_staging_root(Staging) of
|
||||
ok ->
|
||||
case restore_rocksdbs(Dir, Manifest, Staging) of
|
||||
ok ->
|
||||
set_restore_mountpoint_root(Staging),
|
||||
case install_and_apply_fallback(Dir, Manifest, Opts) of
|
||||
ok ->
|
||||
finalize_staging(Staging, Manifest, MnesiaDir,
|
||||
Opts);
|
||||
{error, _} = E ->
|
||||
clear_restore_mountpoint_root(),
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end
|
||||
after
|
||||
clear_restore_mountpoint_root()
|
||||
end.
|
||||
|
||||
finalize_staging(Staging, Manifest, MnesiaDir, Opts) ->
|
||||
clear_restore_mountpoint_root(),
|
||||
case relocate_staging_to_mnesia(Staging, Manifest, MnesiaDir) of
|
||||
ok ->
|
||||
_ = clear_dir(Staging),
|
||||
maybe_start_mnesia(Opts);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
prepare_staging_root(Staging) ->
|
||||
case clear_dir(Staging) of
|
||||
ok ->
|
||||
ensure_dir_tree(Staging);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
set_restore_mountpoint_root(Root) ->
|
||||
Abs = filename:absname(Root),
|
||||
application:set_env(mnesia_rocksdb, restore_mountpoint_root, Abs),
|
||||
%% Keep PT in sync when present (admin may not be running yet).
|
||||
case persistent_term:get(?PT_KEY, undefined) of
|
||||
undefined ->
|
||||
ok;
|
||||
Meta when is_map(Meta) ->
|
||||
persistent_term:put(
|
||||
?PT_KEY, Meta#{ {restore_mountpoint_root} => Abs })
|
||||
end,
|
||||
ok.
|
||||
|
||||
clear_restore_mountpoint_root() ->
|
||||
application:unset_env(mnesia_rocksdb, restore_mountpoint_root),
|
||||
case persistent_term:get(?PT_KEY, undefined) of
|
||||
undefined ->
|
||||
ok;
|
||||
Meta when is_map(Meta) ->
|
||||
persistent_term:put(
|
||||
?PT_KEY, maps:remove({restore_mountpoint_root}, Meta))
|
||||
end,
|
||||
ok.
|
||||
|
||||
install_and_apply_fallback(Dir, Manifest, Opts) ->
|
||||
case install_mnesia_fallback(Dir, Manifest, Opts) of
|
||||
ok ->
|
||||
apply_mnesia_fallback();
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
%% Start Mnesia so FALLBACK.BUP is applied (RocksDB opens from staging if set),
|
||||
%% wait for tables, then stop so files can be relocated to the real Mnesia dir.
|
||||
apply_mnesia_fallback() ->
|
||||
StartRes =
|
||||
case mnesia:start() of
|
||||
ok -> ok;
|
||||
{error, {already_started, mnesia}} -> ok;
|
||||
{error, _} = StartErr -> StartErr
|
||||
end,
|
||||
case StartRes of
|
||||
ok ->
|
||||
case mnesia:system_info(is_running) of
|
||||
yes ->
|
||||
Tabs = mnesia:system_info(tables),
|
||||
case mnesia:wait_for_tables(Tabs, 120000) of
|
||||
ok ->
|
||||
stopped = mnesia:stop(),
|
||||
wait_admin_down(100, 50);
|
||||
{timeout, _} = T ->
|
||||
_ = mnesia:stop(),
|
||||
_ = wait_admin_down(100, 50),
|
||||
{error, T};
|
||||
{error, _} = WaitErr ->
|
||||
_ = mnesia:stop(),
|
||||
_ = wait_admin_down(100, 50),
|
||||
WaitErr
|
||||
end;
|
||||
_ ->
|
||||
{error, fallback_apply_failed}
|
||||
end;
|
||||
{error, _} = Err ->
|
||||
Err
|
||||
end.
|
||||
|
||||
relocate_staging_to_mnesia(Staging, #{dbs := Dbs}, MnesiaDir) ->
|
||||
relocate_loop(Dbs, Staging, MnesiaDir).
|
||||
|
||||
relocate_loop([], _Staging, _MnesiaDir) ->
|
||||
ok;
|
||||
relocate_loop([#{mountpoint := OrigMP} | Rest], Staging, MnesiaDir) ->
|
||||
Base = filename:basename(OrigMP),
|
||||
From = filename:join(Staging, Base),
|
||||
To = filename:join(MnesiaDir, Base),
|
||||
case relocate_one_mountpoint(From, To, MnesiaDir) of
|
||||
ok ->
|
||||
relocate_loop(Rest, Staging, MnesiaDir);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
relocate_one_mountpoint(From, To, MnesiaDir) ->
|
||||
case filelib:is_dir(From) of
|
||||
false ->
|
||||
{error, {missing_staged_mountpoint, From}};
|
||||
true ->
|
||||
case is_under_dir(To, MnesiaDir) of
|
||||
false ->
|
||||
{error, {mountpoint_outside_mnesia_dir, To, MnesiaDir}};
|
||||
true ->
|
||||
case clear_dir(To) of
|
||||
ok ->
|
||||
case ensure_parent_dir(To) of
|
||||
ok ->
|
||||
case file:rename(From, To) of
|
||||
ok -> ok;
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end
|
||||
end
|
||||
end.
|
||||
|
||||
ensure_parent_dir(Path) ->
|
||||
filelib:ensure_dir(filename:join(Path, "dummy")).
|
||||
|
||||
read_manifest(Dir) ->
|
||||
Path = filename:join(Dir, "MANIFEST"),
|
||||
case file:consult(Path) of
|
||||
{ok, [#{version := 1} = M]} ->
|
||||
{ok, M};
|
||||
{ok, [Other]} ->
|
||||
{error, {invalid_manifest, Other}};
|
||||
{ok, Other} ->
|
||||
{error, {invalid_manifest, Other}};
|
||||
{error, enoent} ->
|
||||
{error, {missing_manifest, Path}};
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
target_mnesia_dir(Opts, Manifest) ->
|
||||
case maps:get(mnesia_dir, Opts, undefined) of
|
||||
undefined ->
|
||||
case mnesia_directory() of
|
||||
undefined ->
|
||||
maps:get(mnesia_directory, Manifest);
|
||||
Dir ->
|
||||
Dir
|
||||
end;
|
||||
Dir ->
|
||||
filename:absname(Dir)
|
||||
end.
|
||||
|
||||
mnesia_directory() ->
|
||||
try mnesia:system_info(directory) of
|
||||
Dir when is_list(Dir); is_binary(Dir) -> filename:absname(Dir);
|
||||
_ -> undefined
|
||||
catch
|
||||
_:_ ->
|
||||
case application:get_env(mnesia, dir) of
|
||||
{ok, Dir} -> filename:absname(Dir);
|
||||
undefined -> undefined
|
||||
end
|
||||
end.
|
||||
|
||||
maybe_stop_mnesia(Opts) ->
|
||||
case maps:get(stop, Opts, true) of
|
||||
false ->
|
||||
case mnesia:system_info(is_running) of
|
||||
yes ->
|
||||
{error, {mnesia_running,
|
||||
"stop Mnesia before restore, or pass #{stop => true}"}};
|
||||
_ ->
|
||||
ok
|
||||
end;
|
||||
true ->
|
||||
case mnesia:system_info(is_running) of
|
||||
yes ->
|
||||
stopped = mnesia:stop(),
|
||||
wait_admin_down(100, 50);
|
||||
_ ->
|
||||
%% Best-effort: admin may still hold open DBs if left over.
|
||||
stop_admin_if_alive(),
|
||||
wait_admin_down(100, 50)
|
||||
end
|
||||
end.
|
||||
|
||||
stop_admin_if_alive() ->
|
||||
case whereis(?MODULE) of
|
||||
undefined ->
|
||||
ok;
|
||||
Pid when is_pid(Pid) ->
|
||||
try gen_server:stop(Pid, shutdown, 30000)
|
||||
catch
|
||||
_:_ -> ok
|
||||
end
|
||||
end.
|
||||
|
||||
wait_admin_down(0, _Sleep) ->
|
||||
case whereis(?MODULE) of
|
||||
undefined -> ok;
|
||||
Pid -> {error, {admin_still_running, Pid}}
|
||||
end;
|
||||
wait_admin_down(N, Sleep) ->
|
||||
case whereis(?MODULE) of
|
||||
undefined ->
|
||||
ok;
|
||||
_ ->
|
||||
timer:sleep(Sleep),
|
||||
wait_admin_down(N - 1, Sleep)
|
||||
end.
|
||||
|
||||
maybe_start_mnesia(Opts) ->
|
||||
case maps:get(start, Opts, false) of
|
||||
true ->
|
||||
case mnesia:start() of
|
||||
ok -> ok;
|
||||
{error, {already_started, mnesia}} -> ok;
|
||||
{error, _} = E -> E
|
||||
end;
|
||||
false ->
|
||||
ok
|
||||
end.
|
||||
|
||||
%% Restore RocksDB engines under Root (staging sibling or final Mnesia dir).
|
||||
restore_rocksdbs(Dir, #{dbs := Dbs}, Root) ->
|
||||
restore_rocksdbs_loop(Dir, Dbs, Root).
|
||||
|
||||
restore_rocksdbs_loop(_Dir, [], _Root) ->
|
||||
ok;
|
||||
restore_rocksdbs_loop(Dir, [Spec | Rest], Root) ->
|
||||
case restore_one_db(Dir, Spec, Root) of
|
||||
ok ->
|
||||
restore_rocksdbs_loop(Dir, Rest, Root);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
restore_one_db(Dir, #{ backup_relpath := Rel
|
||||
, backup_id := BackupId
|
||||
, mountpoint := OrigMP }, Root) ->
|
||||
AbsBackup = filename:join(Dir, Rel),
|
||||
TargetMP = remap_mountpoint(OrigMP, Root),
|
||||
case prepare_restore_mountpoint(TargetMP, Root) of
|
||||
ok ->
|
||||
case rocksdb:open_backup_engine(AbsBackup) of
|
||||
{ok, BE} ->
|
||||
try
|
||||
case rocksdb:restore_db_from_backup(
|
||||
BE, BackupId, TargetMP, TargetMP) of
|
||||
ok -> ok;
|
||||
{error, _} = E -> E
|
||||
end
|
||||
after
|
||||
_ = rocksdb:close_backup_engine(BE)
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
{error, _} = E ->
|
||||
E
|
||||
end.
|
||||
|
||||
%% Keep only the basename under Root so restores work when the absolute path
|
||||
%% differs from the backup host (and for staging siblings).
|
||||
remap_mountpoint(OrigMP, Root) ->
|
||||
filename:join(Root, filename:basename(OrigMP)).
|
||||
|
||||
prepare_restore_mountpoint(TargetMP, Root) ->
|
||||
case is_under_dir(TargetMP, Root) of
|
||||
true ->
|
||||
case clear_dir(TargetMP) of
|
||||
ok ->
|
||||
ensure_dir_tree(TargetMP);
|
||||
{error, _} = E ->
|
||||
E
|
||||
end;
|
||||
false ->
|
||||
{error, {mountpoint_outside_root, TargetMP, Root}}
|
||||
end.
|
||||
|
||||
is_under_dir(Path, Root) ->
|
||||
AbsPath = filename:absname(Path),
|
||||
AbsRoot = filename:absname(Root),
|
||||
lists:prefix(AbsRoot ++ "/", AbsPath ++ "/")
|
||||
orelse AbsPath =:= AbsRoot.
|
||||
|
||||
clear_dir(Dir) ->
|
||||
case filelib:is_dir(Dir) of
|
||||
true ->
|
||||
file:del_dir_r(Dir);
|
||||
false ->
|
||||
ok
|
||||
end.
|
||||
|
||||
install_mnesia_fallback(Dir, Manifest, Opts) ->
|
||||
Rel = maps:get(mnesia_backup, Manifest, "mnesia.BUP"),
|
||||
Bup = filename:join(Dir, Rel),
|
||||
case filelib:is_regular(Bup) of
|
||||
false ->
|
||||
{error, {missing_mnesia_backup, Bup}};
|
||||
true ->
|
||||
Mod = maps:get(mnesia_backup_module, Opts,
|
||||
default_backup_module()),
|
||||
Args = [ {module, Mod}
|
||||
, {scope, local}
|
||||
],
|
||||
case mnesia:install_fallback(Bup, Args) of
|
||||
ok -> ok;
|
||||
{error, _} = E -> E
|
||||
end
|
||||
end.
|
||||
|
||||
default_backup_module() ->
|
||||
try mnesia:system_info(backup_module)
|
||||
catch
|
||||
_:_ -> mnesia_backup
|
||||
end.
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
, data_mountpoint/1
|
||||
, create_mountpoint/1
|
||||
, tabname/1
|
||||
, restore_mountpoint_root/0
|
||||
, restore_staging_root/1
|
||||
]).
|
||||
|
||||
-export([ default_encoding/3
|
||||
@@ -59,8 +61,16 @@ create_mountpoint(Tab) ->
|
||||
file:make_dir(MPd),
|
||||
ok;
|
||||
true ->
|
||||
Dir = mnesia_lib:dir(),
|
||||
case lists:prefix(Dir, MPd) of
|
||||
AllowedRoots =
|
||||
case restore_mountpoint_root() of
|
||||
undefined -> [mnesia_lib:dir()];
|
||||
Staging -> [mnesia_lib:dir(), Staging]
|
||||
end,
|
||||
case lists:any(
|
||||
fun(Root) ->
|
||||
lists:prefix(filename:absname(Root),
|
||||
filename:absname(MPd))
|
||||
end, AllowedRoots) of
|
||||
true ->
|
||||
ok;
|
||||
false ->
|
||||
@@ -68,9 +78,37 @@ create_mountpoint(Tab) ->
|
||||
end
|
||||
end.
|
||||
|
||||
%% @doc Absolute RocksDB directory for a logical table / admin DB.
|
||||
%%
|
||||
%% During hybrid restore, `application:get_env(mnesia_rocksdb,
|
||||
%% restore_mountpoint_root)` may point at a sibling staging directory so
|
||||
%% RocksDB data survives Mnesia's fallback purge of the real Mnesia dir.
|
||||
-spec data_mountpoint(term()) -> file:filename().
|
||||
data_mountpoint(Tab) ->
|
||||
Dir = mnesia_monitor:get_env(dir),
|
||||
filename:join(Dir, tabname(Tab) ++ ".extrdb").
|
||||
Base = tabname(Tab) ++ ".extrdb",
|
||||
case restore_mountpoint_root() of
|
||||
undefined ->
|
||||
filename:join(mnesia_monitor:get_env(dir), Base);
|
||||
Root ->
|
||||
filename:join(Root, Base)
|
||||
end.
|
||||
|
||||
%% @doc Staging root used while applying a hybrid restore, if any.
|
||||
-spec restore_mountpoint_root() -> file:filename() | undefined.
|
||||
restore_mountpoint_root() ->
|
||||
case application:get_env(mnesia_rocksdb, restore_mountpoint_root) of
|
||||
{ok, Root} when is_list(Root); is_binary(Root) ->
|
||||
filename:absname(Root);
|
||||
_ ->
|
||||
undefined
|
||||
end.
|
||||
|
||||
%% @doc Sibling of the Mnesia directory for staged RocksDB restore data.
|
||||
-spec restore_staging_root(file:filename()) -> file:filename().
|
||||
restore_staging_root(MnesiaDir) ->
|
||||
Abs = filename:absname(MnesiaDir),
|
||||
filename:join(filename:dirname(Abs),
|
||||
filename:basename(Abs) ++ ".rocksdb_restore").
|
||||
|
||||
tabname({admin, Alias}) ->
|
||||
"mnesia_rocksdb-" ++ atom_to_list(Alias) ++ "-_db";
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
%% -*- mode: erlang; erlang-indent-level: 4; indent-tabs-mode: nil -*-
|
||||
-module(mnesia_rocksdb_backup_SUITE).
|
||||
|
||||
-export([ all/0
|
||||
, suite/0
|
||||
, groups/0
|
||||
, init_per_suite/1
|
||||
, end_per_suite/1
|
||||
, init_per_group/2
|
||||
, end_per_group/2
|
||||
, init_per_testcase/2
|
||||
, end_per_testcase/2
|
||||
]).
|
||||
|
||||
-export([ hybrid_backup_cf_and_disc/1
|
||||
, hybrid_backup_with_standalone/1
|
||||
, backup_rejects_nonempty_dir/1
|
||||
, backup_with_verify/1
|
||||
, restore_roundtrip_cf_and_disc/1
|
||||
, restore_roundtrip_with_standalone/1
|
||||
]).
|
||||
|
||||
-include_lib("common_test/include/ct.hrl").
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
suite() ->
|
||||
[].
|
||||
|
||||
all() ->
|
||||
[{group, all_tests}].
|
||||
|
||||
groups() ->
|
||||
[{all_tests, [sequence],
|
||||
[ hybrid_backup_cf_and_disc
|
||||
, hybrid_backup_with_standalone
|
||||
, backup_rejects_nonempty_dir
|
||||
, backup_with_verify
|
||||
, restore_roundtrip_cf_and_disc
|
||||
, restore_roundtrip_with_standalone
|
||||
]}].
|
||||
|
||||
init_per_suite(Config) ->
|
||||
Config.
|
||||
|
||||
end_per_suite(_Config) ->
|
||||
ok.
|
||||
|
||||
init_per_group(_, Config) ->
|
||||
Config.
|
||||
|
||||
end_per_group(_, _Config) ->
|
||||
ok.
|
||||
|
||||
init_per_testcase(_TC, Config) ->
|
||||
mnesia:stop(),
|
||||
ok = mnesia_rocksdb_tlib:start_mnesia(reset),
|
||||
Config.
|
||||
|
||||
end_per_testcase(_TC, _Config) ->
|
||||
ok.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Tests
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
%% disc_copies (not ram_copies): with ram_overrides_dump=false, Mnesia
|
||||
%% checkpoints prefer the dumped image for ram_copies, so pure RAM writes
|
||||
%% are a poor stand-in for "persistent non-RocksDB tables".
|
||||
hybrid_backup_cf_and_disc(Config) ->
|
||||
create_rdb_table(t, []),
|
||||
{atomic, ok} =
|
||||
mnesia:create_table(d, [{disc_copies, [node()]},
|
||||
{attributes, [k, v]}]),
|
||||
ok = mnesia:wait_for_tables([t, d], 5000),
|
||||
|
||||
[ok = mnesia:dirty_write({t, K, V}) || {K, V} <- [{a, 1}, {b, 2}, {c, 3}]],
|
||||
[ok = mnesia:dirty_write({d, K, V}) || {K, V} <- [{x, 10}, {y, 20}]],
|
||||
|
||||
BackupDir = backup_dir(Config, "cf_disc"),
|
||||
?assertEqual(ok, mnesia_rocksdb:backup(BackupDir)),
|
||||
|
||||
assert_layout(BackupDir),
|
||||
Manifest = read_manifest(BackupDir),
|
||||
ct:log("MANIFEST = ~p", [Manifest]),
|
||||
|
||||
?assertEqual(1, maps:get(version, Manifest)),
|
||||
?assert(lists:member(t, maps:get(rocksdb_tables, Manifest))),
|
||||
?assertNot(lists:member(t, maps:get(mnesia_tables, Manifest))),
|
||||
?assert(lists:member(schema, maps:get(mnesia_tables, Manifest))),
|
||||
?assert(lists:member(d, maps:get(mnesia_tables, Manifest))),
|
||||
|
||||
Dbs = maps:get(dbs, Manifest),
|
||||
?assert(lists:any(fun(#{kind := admin}) -> true; (_) -> false end, Dbs)),
|
||||
|
||||
assert_admin_backup_ok(BackupDir, Dbs),
|
||||
assert_mnesia_bup_has_table(BackupDir, d),
|
||||
assert_mnesia_bup_lacks_table_data(BackupDir, t),
|
||||
ok.
|
||||
|
||||
hybrid_backup_with_standalone(Config) ->
|
||||
create_rdb_table(t, []),
|
||||
create_rdb_table(ts, [{user_properties, [{rocksdb_standalone, true}]}]),
|
||||
{atomic, ok} =
|
||||
mnesia:create_table(d, [{disc_copies, [node()]},
|
||||
{attributes, [k, v]}]),
|
||||
ok = mnesia:wait_for_tables([t, ts, d], 5000),
|
||||
|
||||
ok = mnesia:dirty_write({t, 1, one}),
|
||||
ok = mnesia:dirty_write({ts, 2, two}),
|
||||
ok = mnesia:dirty_write({d, 3, three}),
|
||||
|
||||
#{type := standalone} = mrdb:get_ref(ts),
|
||||
|
||||
BackupDir = backup_dir(Config, "standalone"),
|
||||
?assertEqual(ok, mnesia_rocksdb:backup(BackupDir)),
|
||||
|
||||
Manifest = read_manifest(BackupDir),
|
||||
RdbTabs = maps:get(rocksdb_tables, Manifest),
|
||||
?assert(lists:member(t, RdbTabs)),
|
||||
?assert(lists:member(ts, RdbTabs)),
|
||||
?assert(lists:member(d, maps:get(mnesia_tables, Manifest))),
|
||||
|
||||
Dbs = maps:get(dbs, Manifest),
|
||||
?assert(lists:any(fun(#{kind := standalone, name := ts}) -> true;
|
||||
(_) -> false end, Dbs)),
|
||||
?assert(lists:any(fun(#{kind := admin}) -> true; (_) -> false end, Dbs)),
|
||||
|
||||
lists:foreach(
|
||||
fun(#{backup_relpath := Rel, backup_id := Id}) ->
|
||||
Abs = filename:join(BackupDir, Rel),
|
||||
{ok, BE} = rocksdb:open_backup_engine(Abs),
|
||||
try
|
||||
?assertEqual(ok, rocksdb:verify_backup(BE, Id))
|
||||
after
|
||||
rocksdb:close_backup_engine(BE)
|
||||
end
|
||||
end, Dbs),
|
||||
ok.
|
||||
|
||||
backup_rejects_nonempty_dir(Config) ->
|
||||
create_rdb_table(t, []),
|
||||
ok = mnesia:wait_for_tables([t], 5000),
|
||||
ok = mnesia:dirty_write({t, 1, a}),
|
||||
|
||||
BackupDir = backup_dir(Config, "nonempty"),
|
||||
ok = filelib:ensure_dir(filename:join(BackupDir, "noise")),
|
||||
case file:make_dir(BackupDir) of
|
||||
ok -> ok;
|
||||
{error, eexist} -> ok
|
||||
end,
|
||||
ok = file:write_file(filename:join(BackupDir, "noise"), <<"x">>),
|
||||
|
||||
?assertMatch({error, {not_empty, _}}, mnesia_rocksdb:backup(BackupDir)),
|
||||
ok.
|
||||
|
||||
backup_with_verify(Config) ->
|
||||
create_rdb_table(t, []),
|
||||
ok = mnesia:wait_for_tables([t], 5000),
|
||||
ok = mnesia:dirty_write({t, 1, a}),
|
||||
|
||||
BackupDir = backup_dir(Config, "verify"),
|
||||
?assertEqual(ok, mnesia_rocksdb:backup(BackupDir, #{verify => true})),
|
||||
assert_layout(BackupDir),
|
||||
ok.
|
||||
|
||||
restore_roundtrip_cf_and_disc(Config) ->
|
||||
create_rdb_table(t, []),
|
||||
{atomic, ok} =
|
||||
mnesia:create_table(d, [{disc_copies, [node()]},
|
||||
{attributes, [k, v]}]),
|
||||
ok = mnesia:wait_for_tables([t, d], 5000),
|
||||
|
||||
[ok = mnesia:dirty_write({t, K, V}) || {K, V} <- [{a, 1}, {b, 2}]],
|
||||
[ok = mnesia:dirty_write({d, K, V}) || {K, V} <- [{x, 10}, {y, 20}]],
|
||||
|
||||
BackupDir = backup_dir(Config, "restore_cf"),
|
||||
?assertEqual(ok, mnesia_rocksdb:backup(BackupDir)),
|
||||
|
||||
%% Mutate after backup — restore must discard these changes.
|
||||
ok = mnesia:dirty_write({t, a, 999}),
|
||||
ok = mnesia:dirty_write({t, c, 3}),
|
||||
ok = mnesia:dirty_delete({d, x}),
|
||||
ok = mnesia:dirty_write({d, z, 30}),
|
||||
|
||||
?assertEqual(ok, mnesia_rocksdb:restore(BackupDir, #{start => true})),
|
||||
?assertEqual(ok, mnesia:wait_for_tables([t, d], 10000)),
|
||||
|
||||
?assertEqual([{t, a, 1}, {t, b, 2}],
|
||||
lists:sort(mnesia:dirty_match_object({t, '_', '_'}))),
|
||||
?assertEqual([{d, x, 10}, {d, y, 20}],
|
||||
lists:sort(mnesia:dirty_match_object({d, '_', '_'}))),
|
||||
ok.
|
||||
|
||||
restore_roundtrip_with_standalone(Config) ->
|
||||
create_rdb_table(t, []),
|
||||
create_rdb_table(ts, [{user_properties, [{rocksdb_standalone, true}]}]),
|
||||
{atomic, ok} =
|
||||
mnesia:create_table(d, [{disc_copies, [node()]},
|
||||
{attributes, [k, v]}]),
|
||||
ok = mnesia:wait_for_tables([t, ts, d], 5000),
|
||||
|
||||
ok = mnesia:dirty_write({t, 1, one}),
|
||||
ok = mnesia:dirty_write({ts, 2, two}),
|
||||
ok = mnesia:dirty_write({d, 3, three}),
|
||||
#{type := standalone} = mrdb:get_ref(ts),
|
||||
|
||||
BackupDir = backup_dir(Config, "restore_sa"),
|
||||
?assertEqual(ok, mnesia_rocksdb:backup(BackupDir)),
|
||||
|
||||
ok = mnesia:dirty_write({t, 1, changed}),
|
||||
ok = mnesia:dirty_write({ts, 2, changed}),
|
||||
ok = mnesia:dirty_write({d, 3, changed}),
|
||||
|
||||
?assertEqual(ok, mnesia_rocksdb:restore(BackupDir, #{start => true})),
|
||||
?assertEqual(ok, mnesia:wait_for_tables([t, ts, d], 10000)),
|
||||
|
||||
?assertEqual([{t, 1, one}], mnesia:dirty_read(t, 1)),
|
||||
?assertEqual([{ts, 2, two}], mnesia:dirty_read(ts, 2)),
|
||||
?assertEqual([{d, 3, three}], mnesia:dirty_read(d, 3)),
|
||||
#{type := standalone} = mrdb:get_ref(ts),
|
||||
ok.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Helpers
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
create_rdb_table(Name, Extra) ->
|
||||
{atomic, ok} =
|
||||
mnesia:create_table(Name, [{rdb, [node()]},
|
||||
{attributes, [k, v]} | Extra]),
|
||||
ok.
|
||||
|
||||
backup_dir(Config, Name) ->
|
||||
filename:join(?config(priv_dir, Config), "backup_" ++ Name).
|
||||
|
||||
assert_layout(BackupDir) ->
|
||||
?assert(filelib:is_regular(filename:join(BackupDir, "MANIFEST"))),
|
||||
?assert(filelib:is_regular(filename:join(BackupDir, "mnesia.BUP"))),
|
||||
?assert(filelib:is_dir(filename:join(BackupDir, "rocksdb"))).
|
||||
|
||||
read_manifest(BackupDir) ->
|
||||
Path = filename:join(BackupDir, "MANIFEST"),
|
||||
{ok, [Manifest]} = file:consult(Path),
|
||||
Manifest.
|
||||
|
||||
assert_admin_backup_ok(BackupDir, Dbs) ->
|
||||
[Admin | _] = [D || #{kind := admin} = D <- Dbs],
|
||||
#{backup_relpath := Rel, backup_id := Id} = Admin,
|
||||
Abs = filename:join(BackupDir, Rel),
|
||||
{ok, BE} = rocksdb:open_backup_engine(Abs),
|
||||
try
|
||||
Infos = case rocksdb:get_backup_info(BE) of
|
||||
{ok, L} -> L;
|
||||
L when is_list(L) -> L
|
||||
end,
|
||||
?assertNotEqual([], Infos),
|
||||
?assertEqual(ok, rocksdb:verify_backup(BE, Id))
|
||||
after
|
||||
rocksdb:close_backup_engine(BE)
|
||||
end.
|
||||
|
||||
assert_mnesia_bup_has_table(BackupDir, Tab) ->
|
||||
Bup = filename:join(BackupDir, "mnesia.BUP"),
|
||||
%% Fun is applied per backup item (not per batch).
|
||||
{ok, Found} =
|
||||
mnesia:traverse_backup(
|
||||
Bup, mnesia_backup, read_only, read_only,
|
||||
fun(Rec, _Acc) when is_tuple(Rec), element(1, Rec) =:= Tab ->
|
||||
{[Rec], true};
|
||||
(Rec, Acc) ->
|
||||
{[Rec], Acc}
|
||||
end, false),
|
||||
?assertEqual(true, Found).
|
||||
|
||||
assert_mnesia_bup_lacks_table_data(BackupDir, Tab) ->
|
||||
Bup = filename:join(BackupDir, "mnesia.BUP"),
|
||||
{ok, Found} =
|
||||
mnesia:traverse_backup(
|
||||
Bup, mnesia_backup, read_only, read_only,
|
||||
fun({schema, _, _} = Rec, Acc) ->
|
||||
{[Rec], Acc};
|
||||
(Rec, _Acc) when is_tuple(Rec), element(1, Rec) =:= Tab ->
|
||||
{[Rec], true};
|
||||
(Rec, Acc) ->
|
||||
{[Rec], Acc}
|
||||
end, false),
|
||||
%% Data records for Tab should not appear; schema may still mention Tab.
|
||||
?assertEqual(false, Found).
|
||||
Reference in New Issue
Block a user