% 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(couch_bt_engine_compactor_ev_tests).

-include_lib("couch/include/couch_db.hrl").
-include_lib("couch/include/couch_eunit.hrl").
-include_lib("couch/src/couch_server_int.hrl").

-define(TIMEOUT_EUNIT, 60).
-define(EV_MOD, couch_bt_engine_compactor_ev).
-define(INIT_DOCS, 2500).
-define(WRITE_DOCS, 20).

% The idea behind the tests in this module are to attempt to
% cover the number of restart/recopy events during compaction
% so that we can be as sure as possible that the compactor
% is resilient to errors in the face of external conditions
% (i.e., the VM rebooted). The single linear pass is easy enough
% to prove, however restarting is important enough that we don't
% want to waste work if a VM happens to bounce a lot.
%
% To try and cover as many restart situations we have created a
% number of events in the compactor code that are present during
% a test compiled version of the module. These events can then
% be used (via meck) to introduce errors and coordinate writes
% to the database while compaction is in progress.

% This list of events is where we'll insert our errors.

events() ->
    [
        % The compactor process is spawned
        init,
        % After compaction files have opened
        files_opened,

        % Just before apply purge changes
        purge_init,
        % Just after finish purge updates
        purge_done,

        % The firs phase is when we copy all document body and attachment
        % data to the new database file in order of update sequence so
        % that we can resume on crash.

        % Before the first change is copied
        seq_init,
        % After change N is copied
        {seq_copy, 0},
        {seq_copy, ?INIT_DOCS div 2},
        {seq_copy, ?INIT_DOCS - 2},
        % After last change is copied
        seq_done,

        % The id copy phases come in two flavors. Before a compaction
        % swap is attempted they're copied from the id_tree in the
        % database being compacted. After a swap attempt they are
        % stored in an emsort file on disk. Thus the two sets of
        % related events here.

        % Just before metadata sort starts
        md_sort_init,
        % Justa after metadata sort finished
        md_sort_done,
        % Just before metadata copy starts
        md_copy_init,
        % After docid N is copied
        {md_copy_row, 0},
        {md_copy_row, ?INIT_DOCS div 2},
        {md_copy_row, ?INIT_DOCS - 2},
        % Just after the last docid is copied
        md_copy_done,

        % And then the final steps before we finish

        % Just before final sync
        before_final_sync,
        % Just after the final sync
        after_final_sync,
        % Just before the final notification
        before_notify
    ].

% Mark which evens only happen when documents are present

requires_docs({seq_copy, _}) -> true;
requires_docs(md_sort_init) -> true;
requires_docs(md_sort_done) -> true;
requires_docs(md_copy_init) -> true;
requires_docs({md_copy_row, _}) -> true;
requires_docs(md_copy_done) -> true;
requires_docs(_) -> false.

% Mark which events only happen when there's write activity during
% a compaction.

requires_write(md_sort_init) -> true;
requires_write(md_sort_done) -> true;
requires_write(md_copy_init) -> true;
requires_write({md_copy_row, _}) -> true;
requires_write(md_copy_done) -> true;
requires_write(_) -> false.

setup() ->
    purge_module(),
    ?EV_MOD:init(),
    test_util:start_couch().

teardown(Ctx) ->
    test_util:stop_couch(Ctx),
    ?EV_MOD:terminate().

start_empty_db_test(_Event) ->
    ?EV_MOD:clear(),
    DbName = ?tempdb(),
    {ok, _} = couch_db:create(DbName, [?ADMIN_CTX]),
    DbName.

start_populated_db_test(Event) ->
    DbName = start_empty_db_test(Event),
    {ok, Db} = couch_db:open_int(DbName, []),
    try
        populate_db(Db, ?INIT_DOCS)
    after
        couch_db:close(Db)
    end,
    DbName.

stop_test(_Event, DbName) ->
    couch_server:delete(DbName, [?ADMIN_CTX]).

static_empty_db_test_() ->
    FiltFun = fun(E) ->
        not (requires_docs(E) or requires_write(E))
    end,
    Events = lists:filter(FiltFun, events()) -- [init],
    {
        "Idle empty database",
        {
            setup,
            fun setup/0,
            fun teardown/1,
            [
                {
                    foreachx,
                    fun start_empty_db_test/1,
                    fun stop_test/2,
                    [{Event, fun run_static_init/2} || Event <- Events]
                }
            ]
        }
    }.

static_populated_db_test_() ->
    FiltFun = fun(E) -> not requires_write(E) end,
    Events = lists:filter(FiltFun, events()) -- [init],
    {
        "Idle populated database",
        {
            setup,
            fun setup/0,
            fun teardown/1,
            [
                {
                    foreachx,
                    fun start_populated_db_test/1,
                    fun stop_test/2,
                    [{Event, fun run_static_init/2} || Event <- Events]
                }
            ]
        }
    }.

dynamic_empty_db_test_() ->
    FiltFun = fun(E) -> not requires_docs(E) end,
    Events = lists:filter(FiltFun, events()) -- [init],
    {
        "Writes to empty database",
        {
            setup,
            fun setup/0,
            fun teardown/1,
            [
                {
                    foreachx,
                    fun start_empty_db_test/1,
                    fun stop_test/2,
                    [{Event, fun run_dynamic_init/2} || Event <- Events]
                }
            ]
        }
    }.

dynamic_populated_db_test_() ->
    Events = events() -- [init],
    {
        "Writes to populated database",
        {
            setup,
            fun setup/0,
            fun teardown/1,
            [
                {
                    foreachx,
                    fun start_populated_db_test/1,
                    fun stop_test/2,
                    [{Event, fun run_dynamic_init/2} || Event <- Events]
                }
            ]
        }
    }.

run_static_init(Event, DbName) ->
    Name = lists:flatten(io_lib:format("~p", [Event])),
    Test = {timeout, ?TIMEOUT_EUNIT, ?_test(run_static(Event, DbName))},
    {Name, Test}.

run_static(Event, DbName) ->
    {ok, ContinueFun} = ?EV_MOD:set_wait(init),
    {ok, Reason} = ?EV_MOD:set_crash(Event),
    {ok, Db} = couch_db:open_int(DbName, []),
    Ref = couch_db:monitor(Db),
    {ok, CPid} = couch_db:start_compact(Db),
    ContinueFun(CPid),
    receive
        {'DOWN', Ref, _, _, Reason} ->
            wait_db_cleared(Db)
    end,
    run_successful_compaction(DbName),
    couch_db:close(Db).

run_dynamic_init(Event, DbName) ->
    Name = lists:flatten(io_lib:format("~p", [Event])),
    Test = {timeout, ?TIMEOUT_EUNIT, ?_test(run_dynamic(Event, DbName))},
    {Name, Test}.

run_dynamic(Event, DbName) ->
    {ok, ContinueFun} = ?EV_MOD:set_wait(init),
    {ok, Reason} = ?EV_MOD:set_crash(Event),
    {ok, Db} = couch_db:open_int(DbName, []),
    Ref = couch_db:monitor(Db),
    {ok, CPid} = couch_db:start_compact(Db),
    ok = populate_db(Db, 10),
    ContinueFun(CPid),
    receive
        {'DOWN', Ref, _, _, Reason} ->
            wait_db_cleared(Db)
    end,
    run_successful_compaction(DbName),
    couch_db:close(Db).

run_successful_compaction(DbName) ->
    ?EV_MOD:clear(),
    {ok, ContinueFun} = ?EV_MOD:set_wait(init),
    {ok, Db} = couch_db:open_int(DbName, []),
    {ok, CPid} = couch_db:start_compact(Db),
    Ref = monitor(process, CPid),
    ContinueFun(CPid),
    receive
        {'DOWN', Ref, _, _, normal} -> ok
    end,
    Pid = couch_db:get_pid(Db),
    {ok, NewDb} = gen_server:call(Pid, get_db),
    validate_compaction(NewDb),
    couch_db:close(Db).

wait_db_cleared(Db) ->
    wait_db_cleared(Db, 5).

wait_db_cleared(Db, N) when N < 0 ->
    error({db_clear_timeout, couch_db:name(Db)});
wait_db_cleared(Db, N) ->
    Tab = couch_server:couch_dbs(couch_db:name(Db)),
    case ets:lookup(Tab, couch_db:name(Db)) of
        [] ->
            ok;
        [#entry{db = NewDb}] ->
            OldPid = couch_db:get_pid(Db),
            NewPid = couch_db:get_pid(NewDb),
            if
                NewPid /= OldPid ->
                    ok;
                true ->
                    timer:sleep(100),
                    wait_db_cleared(Db, N - 1)
            end
    end.

populate_db(_Db, NumDocs) when NumDocs =< 0 ->
    ok;
populate_db(Db, NumDocs) ->
    String = [$a || _ <- lists:seq(1, min(NumDocs, 500))],
    Docs = lists:map(
        fun(_) ->
            couch_doc:from_json_obj(
                {[
                    {<<"_id">>, couch_uuids:random()},
                    {<<"string">>, list_to_binary(String)}
                ]}
            )
        end,
        lists:seq(1, 500)
    ),
    {ok, _} = couch_db:update_docs(Db, Docs, []),
    populate_db(Db, NumDocs - 500).

validate_compaction(Db) ->
    {ok, DocCount} = couch_db:get_doc_count(Db),
    {ok, DelDocCount} = couch_db:get_del_doc_count(Db),
    NumChanges = couch_db:count_changes_since(Db, 0),
    FoldFun = fun(FDI, {PrevId, CountAcc}) ->
        ?assert(FDI#full_doc_info.id > PrevId),
        {ok, {FDI#full_doc_info.id, CountAcc + 1}}
    end,
    {ok, {_, LastCount}} = couch_db:fold_docs(Db, FoldFun, {<<>>, 0}),
    ?assertEqual(DocCount + DelDocCount, LastCount),
    ?assertEqual(NumChanges, LastCount).

purge_module() ->
    case code:which(couch_db_updater) of
        cover_compiled ->
            ok;
        _ ->
            code:delete(couch_db_updater),
            code:purge(couch_db_updater)
    end.
