blob: 321230ce45b3995732090c5ca67c92195a4cef47 [file]
% Licensed under the Apache License, Version 2.0 (the "License"); you may not
% use this file except in compliance with the License. You may obtain a copy of
% the License at
%
% http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
% License for the specific language governing permissions and limitations under
% the License.
-module(mem3_rep_test).
-include_lib("couch/include/couch_eunit.hrl").
-include_lib("couch/include/couch_db.hrl").
-include_lib("couch_mrview/include/couch_mrview.hrl").
-include_lib("mem3/include/mem3.hrl").
-define(ID, <<"_id">>).
% seconds
-define(TIMEOUT, 60).
setup() ->
{AllSrc, AllTgt} = {?tempdb(), ?tempdb()},
{PartSrc, PartTgt} = {?tempdb(), ?tempdb()},
Localdb = ?tempdb(),
create_db(AllSrc, [{q, 1}, {n, 1}]),
create_db(AllTgt, [{q, 2}, {n, 1}]),
PartProps = [{partitioned, true}, {hash, [couch_partition, hash, []]}],
create_db(PartSrc, [{q, 1}, {n, 1}, {props, PartProps}]),
create_db(PartTgt, [{q, 2}, {n, 1}, {props, PartProps}]),
create_local_db(Localdb),
meck:new(mem3, [passthrough]),
#{
allsrc => AllSrc,
alltgt => AllTgt,
partsrc => PartSrc,
parttgt => PartTgt,
localdb => Localdb
}.
teardown(#{} = Dbs) ->
meck:unload(),
maps:map(
fun
(localdb, Db) -> delete_local_db(Db);
(_, Db) -> delete_db(Db)
end,
Dbs
).
start_couch() ->
test_util:start_couch([mem3, fabric]).
stop_couch(Ctx) ->
test_util:stop_couch(Ctx).
mem3_reshard_db_test_() ->
{
"mem3 rep db tests",
{
setup,
fun start_couch/0,
fun stop_couch/1,
{
foreach,
fun setup/0,
fun teardown/1,
[
?TDEF_FE(replicate_basics, ?TIMEOUT),
?TDEF_FE(replicate_small_batches, ?TIMEOUT),
?TDEF_FE(replicate_low_batch_count, ?TIMEOUT),
?TDEF_FE(replicate_with_partitions, ?TIMEOUT),
?TDEF_FE(replicate_to_and_from_local, ?TIMEOUT),
?TDEF_FE(replicate_with_purges, ?TIMEOUT),
?TDEF_FE(clean_purge_checkpoints, ?TIMEOUT)
]
}
}
}.
replicate_basics(#{allsrc := AllSrc, alltgt := AllTgt}) ->
DocSpec = #{docs => 10, delete => [5, 9]},
add_test_docs(AllSrc, DocSpec),
SDocs = get_all_docs(AllSrc),
[Src] = lists:sort(mem3:local_shards(AllSrc)),
[Tgt1, Tgt2] = lists:sort(mem3:local_shards(AllTgt)),
#shard{range = R1} = Tgt1,
#shard{range = R2} = Tgt2,
TMap = #{R1 => Tgt1, R2 => Tgt2},
Opts = [{batch_size, 1000}, {batch_count, all}],
?assertMatch({ok, 0}, mem3_rep:go(Src, TMap, Opts)),
?assertEqual(SDocs, get_all_docs(AllTgt)).
replicate_small_batches(#{allsrc := AllSrc, alltgt := AllTgt}) ->
DocSpec = #{docs => 10, delete => [5, 9]},
add_test_docs(AllSrc, DocSpec),
SDocs = get_all_docs(AllSrc),
[Src] = lists:sort(mem3:local_shards(AllSrc)),
[Tgt1, Tgt2] = lists:sort(mem3:local_shards(AllTgt)),
#shard{range = R1} = Tgt1,
#shard{range = R2} = Tgt2,
TMap = #{R1 => Tgt1, R2 => Tgt2},
Opts = [{batch_size, 2}, {batch_count, all}],
?assertMatch({ok, 0}, mem3_rep:go(Src, TMap, Opts)),
?assertEqual(SDocs, get_all_docs(AllTgt)).
replicate_low_batch_count(#{allsrc := AllSrc, alltgt := AllTgt}) ->
DocSpec = #{docs => 10, delete => [5, 9]},
add_test_docs(AllSrc, DocSpec),
SDocs = get_all_docs(AllSrc),
[Src] = lists:sort(mem3:local_shards(AllSrc)),
[Tgt1, Tgt2] = lists:sort(mem3:local_shards(AllTgt)),
#shard{range = R1} = Tgt1,
#shard{range = R2} = Tgt2,
TMap = #{R1 => Tgt1, R2 => Tgt2},
Opts1 = [{batch_size, 2}, {batch_count, 1}],
?assertMatch({ok, 8}, mem3_rep:go(Src, TMap, Opts1)),
Opts2 = [{batch_size, 1}, {batch_count, 2}],
?assertMatch({ok, 6}, mem3_rep:go(Src, TMap, Opts2)),
Opts3 = [{batch_size, 1000}, {batch_count, all}],
?assertMatch({ok, 0}, mem3_rep:go(Src, TMap, Opts3)),
?assertEqual(SDocs, get_all_docs(AllTgt)).
replicate_with_partitions(#{partsrc := PartSrc, parttgt := PartTgt}) ->
DocSpec = #{
pdocs => #{
<<"PX">> => 15,
<<"PY">> => 19
}
},
add_test_docs(PartSrc, DocSpec),
SDocs = get_all_docs(PartSrc),
PXSrc = get_partition_info(PartSrc, <<"PX">>),
PYSrc = get_partition_info(PartSrc, <<"PY">>),
[Src] = lists:sort(mem3:local_shards(PartSrc)),
[Tgt1, Tgt2] = lists:sort(mem3:local_shards(PartTgt)),
#shard{range = R1} = Tgt1,
#shard{range = R2} = Tgt2,
TMap = #{R1 => Tgt1, R2 => Tgt2},
Opts = [{batch_size, 1000}, {batch_count, all}],
?assertMatch({ok, 0}, mem3_rep:go(Src, TMap, Opts)),
?assertEqual(PXSrc, get_partition_info(PartTgt, <<"PX">>)),
?assertEqual(PYSrc, get_partition_info(PartTgt, <<"PY">>)),
?assertEqual(SDocs, get_all_docs(PartTgt)).
replicate_with_purges(#{allsrc := AllSrc, alltgt := AllTgt}) ->
DocSpec = #{docs => 10, delete => [5, 9], purge => [2, 4]},
add_test_docs(AllSrc, DocSpec),
% Add and purge some docs on target to excercise the pull_purges code path
add_test_docs(AllTgt, #{docs => 3, purge => [0, 2]}),
[Src] = lists:sort(mem3:local_shards(AllSrc)),
[Tgt1, Tgt2] = lists:sort(mem3:local_shards(AllTgt)),
#shard{range = R1} = Tgt1,
#shard{range = R2} = Tgt2,
TMap = #{R1 => Tgt1, R2 => Tgt2},
Opts = [{batch_size, 1000}, {batch_count, all}],
?assertMatch({ok, 0}, mem3_rep:go(Src, TMap, Opts)),
SDocs = get_all_docs(AllSrc),
% Purges from the target should have been pulled and removed docs 0,1,2.
% Source should have no live docs.
?assertEqual(#{}, SDocs),
?assertEqual(#{}, get_all_docs(AllTgt)).
clean_purge_checkpoints(#{allsrc := AllSrc, alltgt := AllTgt}) ->
DocSpec = #{docs => 10, delete => [5, 9], purge => [2, 4]},
add_test_docs(AllSrc, DocSpec),
% Add and purge some docs on target to excercise the pull_purges code path
add_test_docs(AllTgt, #{docs => 3, purge => [0, 2]}),
[Src] = lists:sort(mem3:local_shards(AllSrc)),
[Tgt1, Tgt2] = lists:sort(mem3:local_shards(AllTgt)),
#shard{name = SrcName} = Src,
% Since we don't have multiple nodes running and are just replicating
% from one clustered db to another, we need to patch up the shard map
% during the replication so it looks like targets are part of the shard maps
meck:expect(mem3, shards, fun(DbName) ->
case DbName == Src#shard.dbname of
true -> [Src, Tgt1, Tgt2];
false -> meck:passthrough([DbName])
end
end),
FakeTarget = 'nonexistenttarget@127.0.0.1',
% Add a mix of stale, invalid or deprecated purge checkpoints
[Uuid1, Uuid2, Uuid3] = [couch_uuids:random() || _ <- lists:seq(1, 3)],
CheckpointIds = couch_util:with_db(SrcName, fun(Db) ->
Uuid = couch_db:get_uuid(Db),
Docs = [
% This one is ok and should not be cleaned up
#doc{
id = <<"_local/purge-mem3-", Uuid/binary, "-", Uuid1/binary>>,
body =
{[
{<<"type">>, <<"internal_replicator">>},
{<<"updated_on">>, os:system_time(second)},
{<<"purge_seq">>, 10042},
{<<"source">>, atom_to_binary(Src#shard.node, latin1)},
{<<"target">>, atom_to_binary(Tgt1#shard.node, latin1)},
{<<"range">>, Tgt1#shard.range}
]}
},
% Non-existent range. Should be cleaned up.
#doc{
id = <<"_local/purge-mem3-", Uuid/binary, "-", Uuid2/binary>>,
body =
{[
{<<"type">>, <<"internal_replicator">>},
{<<"updated_on">>, os:system_time(second)},
{<<"purge_seq">>, 10043},
{<<"source">>, atom_to_binary(Src#shard.node, latin1)},
{<<"target">>, atom_to_binary(Tgt1#shard.node, latin1)},
{<<"range">>, [0, 1]}
]}
},
% Non-existent target. Should be cleaned up.
#doc{
id = <<"_local/purge-mem3-", Uuid/binary, "-", Uuid3/binary>>,
body =
{[
{<<"type">>, <<"internal_replicator">>},
{<<"updated_on">>, os:system_time(second)},
{<<"purge_seq">>, 10044},
{<<"source">>, atom_to_binary(Src#shard.node, latin1)},
{<<"target">>, atom_to_binary(FakeTarget, latin1)},
{<<"range">>, Tgt1#shard.range}
]}
},
% Deprecated checkpoint format before 3.6. These were redundant
% purge checkpoints generated by purge pulls from target to source
% and checkpointed on the target as ...-$source_uuid-$target_uuid.
% The shard we're writing this fake checkpoint to is the "target"
% shard, and the (source) uuid is the remote source uuid, that's
% why it is Uuid1 and doesn't match ours. Another way to look at
% it: we only should have checkpoints for which the $source_uuid
% matches the current shard uuid, anything else will be cleaned up.
#doc{
id = <<"_local/purge-mem3-", Uuid1/binary, "-", Uuid/binary>>,
body =
{[
{<<"type">>, <<"internal_replicator">>},
{<<"updated_on">>, os:system_time(second)},
{<<"purge_seq">>, 10045},
{<<"source">>, atom_to_binary(Src#shard.node, latin1)},
{<<"target">>, atom_to_binary(Tgt1#shard.node, latin1)},
{<<"range">>, Tgt1#shard.range}
]}
}
],
{ok, _} = couch_db:update_docs(Db, Docs, []),
[Id || #doc{id = Id} <- Docs]
end),
#shard{range = R1} = Tgt1,
#shard{range = R2} = Tgt2,
TMap = #{R1 => Tgt1, R2 => Tgt2},
Opts = [{batch_size, 1000}, {batch_count, all}],
?assertMatch({ok, 0}, mem3_rep:go(Src, TMap, Opts)),
SDocs = get_all_docs(AllSrc),
% Purges from the target should have been pulled and removed docs 0,1,2.
% Source should have no live docs.
?assertEqual(#{}, SDocs),
?assertEqual(#{}, get_all_docs(AllTgt)),
% From the purge checkpoint doc ids we only expect the first one to survive
[Id1, Id2, Id3, Id4] = CheckpointIds,
LocalDocs = local_docs(SrcName),
?assert(is_map_key(Id1, LocalDocs)),
?assertNot(is_map_key(Id2, LocalDocs)),
?assertNot(is_map_key(Id3, LocalDocs)),
?assertNot(is_map_key(Id4, LocalDocs)).
replicate_to_and_from_local(#{localdb := LocalDb, allsrc := ClusteredDb}) ->
% We'll just tests that we can pull purges from the target
add_test_docs(ClusteredDb, #{docs => 6, purge => [0, 2]}),
[#shard{name = TgtDbName}] = mem3:local_shards(ClusteredDb),
Opts = [{batch_size, 1000}, {batch_count, all}],
Src1 = #shard{name = LocalDb, node = node()},
Tgt1 = #shard{name = TgtDbName, node = node()},
?assertMatch({ok, 0}, mem3_rep:go(Src1, Tgt1, Opts)),
% Purge a few more docs in clustered db
add_test_docs(ClusteredDb, #{purge => [3, 4]}),
% Replicate the other way: from clustered to source
Src2 = #shard{name = TgtDbName, node = node()},
Tgt2 = #shard{name = LocalDb, node = node()},
?assertMatch({ok, 0}, mem3_rep:go(Src2, Tgt2, Opts)),
SDocs = get_all_docs(ClusteredDb),
?assertEqual(1, map_size(SDocs)),
?assertMatch(#{<<"00005">> := #{}}, SDocs).
get_partition_info(DbName, Partition) ->
with_proc(fun() ->
{ok, PInfo} = fabric:get_partition_info(DbName, Partition),
maps:with(
[
<<"doc_count">>, <<"doc_del_count">>, <<"partition">>
],
to_map(PInfo)
)
end).
get_all_docs(DbName) ->
get_all_docs(DbName, #mrargs{}).
get_all_docs(DbName, #mrargs{} = QArgs0) ->
GL = group_leader(),
with_proc(
fun() ->
Cb = fun
({row, Props}, Acc) ->
Doc = to_map(couch_util:get_value(doc, Props)),
#{?ID := Id} = Doc,
{ok, Acc#{Id => Doc}};
({meta, _}, Acc) ->
{ok, Acc};
(complete, Acc) ->
{ok, Acc}
end,
QArgs = QArgs0#mrargs{include_docs = true},
{ok, Docs} = fabric:all_docs(DbName, Cb, #{}, QArgs),
Docs
end,
GL
).
to_map([_ | _] = Props) ->
to_map({Props});
to_map({[_ | _]} = EJson) ->
jiffy:decode(jiffy:encode(EJson), [return_maps]).
create_db(DbName, Opts) ->
GL = group_leader(),
with_proc(fun() -> fabric:create_db(DbName, Opts) end, GL).
delete_db(DbName) ->
GL = group_leader(),
with_proc(fun() -> fabric:delete_db(DbName, [?ADMIN_CTX]) end, GL).
create_local_db(DbName) ->
{ok, _} = couch_server:create(DbName, []),
ok.
delete_local_db(DbName) ->
couch_server:delete(DbName, []).
with_proc(Fun) ->
with_proc(Fun, undefined, 30000).
with_proc(Fun, GroupLeader) ->
with_proc(Fun, GroupLeader, 30000).
with_proc(Fun, GroupLeader, Timeout) ->
{Pid, Ref} = spawn_monitor(fun() ->
case GroupLeader of
undefined -> ok;
_ -> group_leader(GroupLeader, self())
end,
exit({with_proc_res, Fun()})
end),
receive
{'DOWN', Ref, process, Pid, {with_proc_res, Res}} ->
Res;
{'DOWN', Ref, process, Pid, Error} ->
error(Error)
after Timeout ->
demonitor(Ref, [flush]),
exit(Pid, kill),
error({with_proc_timeout, Fun, Timeout})
end.
add_test_docs(DbName, #{} = DocSpec) ->
Docs =
docs(maps:get(docs, DocSpec, [])) ++
pdocs(maps:get(pdocs, DocSpec, #{})),
Res = update_docs(DbName, Docs),
Docs1 = lists:map(
fun({Doc, {ok, {RevPos, Rev}}}) ->
Doc#doc{revs = {RevPos, [Rev]}}
end,
lists:zip(Docs, Res)
),
case delete_docs(maps:get(delete, DocSpec, []), Docs1) of
[] -> ok;
[_ | _] = Deleted -> update_docs(DbName, Deleted)
end,
purge_docs(DbName, maps:get(purge, DocSpec, [])).
update_docs(DbName, Docs) ->
with_proc(fun() ->
case fabric:update_docs(DbName, Docs, [?ADMIN_CTX]) of
{accepted, Res} -> Res;
{ok, Res} -> Res
end
end).
delete_docs([S, E], Docs) when E >= S ->
ToDelete = [doc_id(<<"">>, I) || I <- lists:seq(S, E)],
lists:filtermap(
fun(#doc{id = Id} = Doc) ->
case lists:member(Id, ToDelete) of
true -> {true, Doc#doc{deleted = true}};
false -> false
end
end,
Docs
);
delete_docs(_, _) ->
[].
purge_docs(DbName, [S, E]) when E >= S ->
Ids = [doc_id(<<"">>, I) || I <- lists:seq(S, E)],
IdRevs = [{Id, get_revs(DbName, Id)} || Id <- Ids],
{ok, _} = fabric:purge_docs(DbName, IdRevs, []),
ok;
purge_docs(_DbName, []) ->
ok.
get_revs(DbName, DocId) ->
FDI = fabric:get_full_doc_info(DbName, DocId, []),
#doc_info{revs = Revs} = couch_doc:to_doc_info(FDI),
[Rev#rev_info.rev || Rev <- Revs].
pdocs(#{} = PMap) ->
maps:fold(
fun(Part, DocSpec, DocsAcc) ->
docs(DocSpec, <<Part/binary, ":">>) ++ DocsAcc
end,
[],
PMap
).
docs(DocSpec) ->
docs(DocSpec, <<"">>).
docs(N, Prefix) when is_integer(N), N > 0 ->
docs([0, N - 1], Prefix);
docs([S, E], Prefix) when E >= S ->
[doc(Prefix, I) || I <- lists:seq(S, E)];
docs(_, _) ->
[].
doc(Pref, Id) ->
Body = bodyprops(),
doc(Pref, Id, Body, 42).
doc(Pref, Id, BodyProps, AttSize) ->
#doc{
id = doc_id(Pref, Id),
body = {BodyProps},
atts = atts(AttSize)
}.
doc_id(Pref, Id) ->
IdBin = iolist_to_binary(io_lib:format("~5..0B", [Id])),
<<Pref/binary, IdBin/binary>>.
bodyprops() ->
[
{<<"g">>,
{[
{<<"type">>, <<"Polygon">>},
{<<"coordinates">>, [[[-71.0, 48.4], [-70.0, 48.4], [-71.0, 48.4]]]}
]}}
].
atts(0) ->
[];
atts(Size) when is_integer(Size), Size >= 1 ->
Data = <<<<"x">> || _ <- lists:seq(1, Size)>>,
[
couch_att:new([
{name, <<"att">>},
{type, <<"app/binary">>},
{att_len, Size},
{data, Data}
])
].
local_docs(DbName) ->
{ok, Db} = couch_db:open(DbName, [?ADMIN_CTX]),
FoldFun = fun(#doc{id = DocId, body = Body}, Acc) ->
Map = ?JSON_DECODE(?JSON_ENCODE(Body), [return_maps]),
{ok, Acc#{DocId => Map}}
end,
{ok, Res} = couch_db:fold_local_docs(Db, FoldFun, #{}, []),
couch_db:close(Db),
Res.