%%% -------------------------------------------------------
%%% @author huangyongxing@yeah.net
%%% @doc
%%% 自Erlang OTP 20以来，VM内部机制改变，在热更新文件时，
%%% 如果移除旧代码，有可能引起“大”字面常量(literal)的拷贝。
%%%
%%% 在游戏项目中，配置文件有可能存在大量的大字面常量
%%% （字面常量见相关资料说明，这里不做说明），
%%% 如果相关引用的进程数量多，可能引发大量的内存拷贝开销，
%%% 引发系统保护，kill掉Erlang节点。
%%%
%%% 可能的解决方式为两类：
%%% 减少相关引用进程
%%% 减少配置文件中的“大”字面常量
%%% @since 2020-07-01
%%% @end
%%% -------------------------------------------------------
%%%
%%% 以最具代表性，问题最突出的怪物配置（data_mon）进行考察，
%%% 综合评估结论如下：
%%% =======================================================
%%% 配置读取    性能（us）, 直接读配置为100%基准
%%% 管理方式    优缺点或潜在问题
%%% =======================================================
%%% 原方式      0.031 us,  100.0%
%%% 直接读取    1100个怪物进程 - 两次热更内存暴涨2.85GB
%%% -------------------------------------------------------
%%% ets表管理   0.296 us， 954.9%
%%%             热更管理麻烦，对于高频率访问的配置表，
%%%             有较难以解决的中间状态问题，性能上倒无明显问题
%%%             理论上可能存在两种潜在的状态对应问题：
%%%             新代码-旧配置  旧代码-新配置
%%% -------------------------------------------------------
%%% 特定进程      2.0 us, 6451.7% （涉及进程通信，精度稍低一点）
%%% 读取配置    1. 对高负载进程有一定负面性能影响，
%%%                普通功能配置可接受，但是涉及场景进程不太适合
%%%             2. 存在单点瓶颈
%%%                单点服务进程只做请求转发，
%%%                具体读取配置和回应消息处理由其他工作进程情况下，
%%%                瓶颈大约为1秒处理50w左右的请求量，
%%%                如果到100w+级别性能急遽下降。
%%%                需要分拆为多个服务进程分流请求，以突破瓶颈
%%% -------------------------------------------------------
%%% 拆散大常量  未做实际测试，性能参数与直接读取配置对比可能稍逊，
%%%             （因为需要在运行时重新生成数据，不能直接用大常量数据）
%%%             但是可以肯定应当好于ets表。
%%%             缺点：
%%%             部分常量属于策划配置数据，不便拆散，只能从整体结构上拆散。
%%%             热更新时单节点预估大约上涨100MB内存，产品线叠服多风险仍很大
%%% -------------------------------------------------------
%%% 拆分N个模块 性能未做测试，从原理上判断，应与直接读取配置相当
%%%            （多一步简单数学运算，判断配置真实子模块）
%%%             优点：
%%%             1. 本身单模块字面常量数量下降，降低了单个进程拷贝消耗
%%%             2. 减少了单个子模块的相关引用进程数量
%%%             极端情况全部子模块热更时，全部相关进程需要进行字面常量拷贝，
%%%             但是单模块字面常量下降，可以简单粗暴评估为1/N的拷贝量。
%%%             一般情况下不会全部子模块热更，影响进程数量也大大下降，
%%%             视乎热更模块数量才能决定影响到的进程数量，在较好的情况下，
%%%             可以简单评估内存增量开销为原始方式的 1/(N^2)
%%% -------------------------------------------------------
%%% 拆分N个模块 0.172us，554.8%
%%% +拆散大常量 优缺点参考拆散大常量与拆分模块的优缺点。无明显问题。
%%%             这种方式，可以最大程度减小开销，负面影响不大，
%%%             实现代价也相对低廉，不会过于复杂
%%% =======================================================
%%%
%%% =======================================================
%%% 最终选定方案：
%%% 怪物模块配置的读取应采用“拆分N个模块” + “拆散大常量”方式进行
%%% 其他可能存在风险的配置的管理，可以按实现代价及性能需求自行选择，
%%% 大多数一般性的配置，字面常量拷贝问题不存在或非常微弱，不需要做特别管理。
%%%
%%% -------------------------------------------------------
%%% 对data_mon模块，进行子模块拆分 + 拆散大字面常量的测试结果：
%%% -------------------------------------------------------
%%% 测试时间： 2020-07-03
%%% 测试环境：
%%% OS   Win10
%%%      Erlang OTP 22.3
%%% CPU  i5-7500 4核心4线程 3.4GHz
%%% 内存 16GB
%%% -------------------------------------------------------
%%%
%%% 节点启动2分钟后测试（此时常驻场景和怪物全部启动完毕，内存稳定）
%%% 此时情况： 1195个怪物，123个场景，无玩家在线。
%%%
%%% OTP22.3版本，热更2次内存变化情况表：
%%% =======================================================================
%%%                | 原始未拆分literal | 拆分literal    | 拆分literal
%%%                | 单个data_mon配置  | 拆分10个子模块 | 拆分5个子模块
%%% -----------------------------------------------------------------------
%%%                | (初始应该是大常量 |（初始是重新组合生成的数据，
%%%                | 的引用，所以内存  | 需要存储到进程中，
%%%                | 占用反而小)       | 所以内存相对比未拆分大常量占用多）
%%% -----------------------------------------------------------------------
%%% 热| Total      |           61 MB   |         71 MB  |         71 MB
%%%   | Processes  |           26 MB   |         37 MB  |         37 MB
%%% 更| Atoms      |          790 kB   |        782 kB  |        782 kB
%%%   | Binaries   |          443 kB   |        312 kB  |        456 kB
%%% 前| Code       |           16 MB   |         17 MB  |         17 MB
%%%   | ETS        |         3611 kB   |       3620 kB  |       3611 kB
%%% -----------------------------------------------------------------------
%%% 热| Total      |         3143 MB   |         87 MB  |         97 MB
%%% 更| Processes  |         3106 MB   |         52 MB  |         61 MB
%%% 两| Atoms      |          790 kB   |        790 kB  |        782 kB
%%% 次| Binaries   |         1136 kB   |        290 kB  |        500 kB
%%% 后| Code       |           16 MB   |         19 MB  |         19 MB
%%%   | ETS        |         3611 kB   |       3627 kB  |       3612 kB
%%% -----------------------------------------------------------------------
%%% 热|怪物内存avg |    6625.20167 B   | 16686.51046 B  | 16686.51046 B
%%% 更|怪物内存sum |       7917116 B   |    19940380 B  |    19940380 B
%%% 前|场景内存avg |   68618.92683 B   | 64232.58537 B  | 65385.82114 B
%%%   |怪物内存sum |       8440128 B   |     7900608 B  |     8042456 B
%%% -----------------------------------------------------------------------
%%% 热|怪物内存avg | 2551363.04937 B   | 28779.95314 B  | 37518.73808 B
%%% 更|怪物内存sum |    3048878844 B   |    34392044 B  |    44834892 B
%%% 两|场景内存avg | 1576856.58537 B   | 65871.86992 B  | 63482.40650 B
%%% 次|怪物内存sum |     193953360 B   |     8102240 B  |     7808336 B
%%% -----------------------------------------------------------------------
%%%
%%% 其他一些测试情况说明：
%%% 1. 不拆分多个子模块，只分解大常量，之前测试过大约上涨68MB内存。
%%% 这里未再次测试，没有记录精确数据。但是基本上可以印证拆分模块节省拷贝量。
%%%
%%% 2. 首次热更时未记录精准数据，拆分模块，总内存上涨约2-3MB，
%%% 应当是旧代码模块的存储开销，上涨符合正常波动，可忽略不计。
%%%
%%% 3. OTP 19.3版本，未拆分模块，热更新2次，大约上涨6MB内存
%%% -------------------------------------------------------
-module(test_cfg).

-behaviour(gen_server).

%% TEST API
-export([
        test_by_call/1
        ,test_by_code/1
        ,test_by_ets/1
        ,flush_msgs/0
        ,flush_msgs/1
    ]).

%% API
-export([
        start/0
        ,stop/0
        ,cfg_get/1
    ]).

%% gen_server callbacks
-export([init/1,
        handle_call/3,
        handle_cast/2,
        handle_info/2,
        terminate/2,
        code_change/3
    ]).

-define(SERVER, ?MODULE).

-record(state, {
        all_workers = {[], []}
    }).

%% 单进程管理方式工作进程数
-define(WORKER_CNT, 10).

%% ETS测试用表及进程名
-define(PROC_ETS_TEST, proc_ets_test).
-define(TAB_ETS_TEST, tab_ets_test).


%%%===================================================================
%%% TEST API
%%%===================================================================


%% 测试接口 - call单进程读取配置
%%
%% 性能参数：
%% 经测试，并发情况下，1秒内大约最多可查询43w次配置
%% （跨进程通讯、monitor等操作本身带来了一些额外开销）
%% 平均单次查询操作耗时约2us （大约是直接读取配置的 60多 倍开销）
%%
%% 单点瓶颈：
%% 当测试N比较大时，例如100w，容易引起单进程收消息gc时性能下降，
%% 从而阻塞消息，引发服务不可用问题
%% （实际上阻塞的是发起测试的进程，
%% 该进程需要花更长的时候才能接收完消息，
%% 而管理进程的工作进程较多，可承受上限会高一些
%% ）
%%
%% 如果实际应用，可以考虑不同场景单独开一个进程做怪物配置读取，
%% 再另起一个公共进程给其他地方做配置读取，不要单点入口会更好一些
%% -------------------------------------------------------
%% test_cfg:test_by_call(1000000).
%%
%% start time: {{2020,7,1},{19,11,20}}
%% test N times: [1000000]
%% end  time: {{2020,7,1},{19,11,21}}
%% cost time: 1000ms, IsTimeout:true, progress:[408582/1000000], avg or so about:2 us
%% flush rest msgs
%% flush all cost time:1953000 us
%% ok
%% -------------------------------------------------------
test_by_call(N) when N > 0 ->
    ensure_svr_start(),
    Timeout = 1000,
    CounterPid = self(),
    %% 挑了配置中不同位置几个真实怪物id
    _MonTypeIdList = [12040025, 56000124, 70201004, 400030025, 620030001],
    io:format("~nstart time: ~w~n"
        "test N times: [~w]~n",
        [erlang:localtime(), N]
    ),
    StartTime = utime:unixtime(ms),
    proc_lib:spawn(
        fun () ->
                n_spawn(N,
                    fun () ->
                            % MonTypeId = urand:list_rand(_MonTypeIdList),
                            MonTypeId = 400030025,
                            _Cfg = cfg_get(MonTypeId),
                            CounterPid ! {cfg_get, ok}
                    end)
        end),
    ResultCount = receive_results(utime:unixtime(ms) + Timeout, 0, N),
    EndTime   = utime:unixtime(ms),
    CostTime  = EndTime - StartTime,    % 毫秒
    IsTimeout = CostTime >= Timeout,
    io:format("end  time: ~w~n", [erlang:localtime()]),
    %% 粗略的平均时间。在测试次数较大时可以忽略误差
    AvgTime = case ResultCount > 0 of
        true  -> CostTime * 1000 div ResultCount;
        false -> null
    end,
    io:format("cost time: ~wms, IsTimeout:~w, progress:[~w/~w], avg or so about:~w us~n",
        [CostTime, IsTimeout, ResultCount, N, AvgTime]
    ),
    io:format("flush rest msgs~n"),
    FlushStart = utime:unixtime(us),
    flush_msgs(),
    FlushEnd = utime:unixtime(us),
    io:format("flush all cost time:~w us~n", [FlushEnd - FlushStart]),
    ok.

%% 测试接口 - 直接读取data_mon配置
%%
%% 直接读取data_mon的配置，平均时间大约0.032us左右，而且没有消息阻塞之忧
%%
%% 这里测试主要用于提供对比依据，
%% 但是在Erlang OTP 20+ 存在热更时字面常量拷贝引起的内存暴涨的严重问题，
%% 1. 如果不改data_mon配置（分解大常量），
%%    并且不改怪物设计（缩减怪物进程）的情况下，是一定不能用这种方式的
%%
%% 2. 分解大常量的方式需要修改data_mon，已经单独测试过，
%%    热更新引起的内存暴涨问题可以很大程度上解决，会引起一定程度的内存上升，
%%    但是在可接受范围：
%%    空闲服务器，怪物进程数大约1200，热更大约上涨70MB内存
%%    如果不分解大常量，热更大约上涨2.85GB内存 - 不可接受
%%
%% 原始不分解情况：
%% -------------------------------------------------------
%% test_cfg:test_by_code(1000000).
%% "data_mon:get/1" [total: 31(31)ms avg: 0.031(0.031)us]
%% ["data_mon:get/1",31,31]
%% -------------------------------------------------------
%%
%% 分散常量 + 拆分10个子模块
%% -------------------------------------------------------
%% test_cfg:test_by_code(1000000).
%% "data_mon:get/1" [total: 172(172)ms avg: 0.172(0.172)us]
%% ["data_mon:get/1",172,172]
%% -------------------------------------------------------
test_by_code(N) ->
    _MonTypeIdList = [12040025, 56000124, 70201004, 400030025, 620030001],
    ptester:timer("data_mon:get/1", fun(_) ->
                % MonTypeId = urand:list_rand(_MonTypeIdList),
                MonTypeId = 400030025,
                data_mon:get(MonTypeId)
        end, N).

%% 测试接口 - ets表方式读取配置
%% -------------------------------------------------------
%% test_cfg:test_by_ets(1000000).
%% check init ets tab
%% start test:{{2020,7,1},{19,10,11}}
%% "ets:lookup/1" [total: 297(296)ms avg: 0.297(0.296)us]
%% ["ets:lookup/1",297,296]
%% -------------------------------------------------------
test_by_ets(N) ->
    io:format("check init ets tab~n"),
    ensure_init_ets(),
    io:format("start test:~w~n", [erlang:localtime()]),
    _MonTypeIdList = [12040025, 56000124, 70201004, 400030025, 620030001],
    ptester:timer("ets:lookup/1", fun(_) ->
                MonTypeId = 400030025,
                ets:lookup(?TAB_ETS_TEST, MonTypeId)
        end, N).

%% 清空消息使用，默认单次等待500ms，最多试10次
flush_msgs() ->
    WaitTime = 500,
    flush_msgs(WaitTime).

%% 清空消息使用
%% MaxCnt不宜太大，最差情况下，
%% 如果不能正常收取消息，额外的最长等待时长为500*10
flush_msgs(Timeout) ->
    MaxCnt = 10,
    sub_flush_msgs(Timeout, 1, MaxCnt).

%%%===================================================================
%%% API
%%%===================================================================

start() ->
    gen_server:start({local, ?SERVER}, ?MODULE, [], []).

stop() ->
    gen_server:stop(?SERVER).

%% 获取配置
cfg_get(MonTypeId) ->
    gen_server:call(?SERVER, {get, MonTypeId}).

%%%===================================================================
%%% gen_server callbacks
%%%===================================================================

init([]) ->
    process_flag(message_queue_data, off_heap),
    %% 这里粗略测试，worker按顺序分配，实际使用可用poolboy来处理负载更均衡
    AllWorkers = spawn_link_workers(?WORKER_CNT, queue:new()),
    {ok, #state{all_workers = AllWorkers}}.

handle_call({get, MonTypeId}, _From, State) ->
    % MonConfig = data_mon:get(MonTypeId),
    % {reply, MonConfig, State};
    #state{all_workers = AllWorkers} = State,
    {{value, Worker}, WorkerOthers} = queue:out(AllWorkers),
    Worker ! {get, MonTypeId, _From},
    NewWorkers = queue:in(Worker, WorkerOthers),
    {noreply, State#state{all_workers = NewWorkers}};

handle_call(_Request, _From, State) ->
    Reply = ok,
    {reply, Reply, State}.

handle_cast(_Msg, State) ->
    {noreply, State}.

handle_info({'EXIT', Pid, _Reason}, State = #state{all_workers = AllWorkers}) ->
    NewState =
    case queue:member(Pid, AllWorkers) of
        true  ->
            RestWorkers = lists:filter(fun(Worker) -> Worker =/= Pid end, AllWorkers),
            NewWorkers = spawn_link_workers(1, RestWorkers),
            State#state{all_workers = NewWorkers};
        false ->
            State
    end,
    {noreply, NewState};

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

%%%===================================================================
%%% Internal functions
%%%===================================================================

%% -------------------------------------------------------
%% 单进程管理配置读取需求相关内部接口
%% -------------------------------------------------------

%% 单进程管理方式下的子工作进程
cfg_worker() ->
    receive
        {get, MonTypeId, From} ->
            MonConfig = data_mon:get(MonTypeId),
            gen_server:reply(From, MonConfig);
        _ ->
            ignore
    end,
    cfg_worker().

%% 确保配置读取管理进程启动
ensure_svr_start() ->
    case whereis(?SERVER) of
        undefined ->
            start();
        _Pid ->
            ignore
    end.

n_spawn(N, Fun) when N > 0 ->
    proc_lib:spawn(Fun),
    n_spawn(N-1, Fun);
n_spawn(_N, _Fun) ->
    ok.

receive_results(TimeoutMicroSecs, Cnt, MaxCnt) when Cnt < MaxCnt ->
    CurrTime = utime:unixtime(ms),
    Timeout = max(0, TimeoutMicroSecs - CurrTime),
    [IsTimeout, NewCnt] =
    receive
        {cfg_get, ok} ->
            [false, Cnt+1];
        _ ->
            [false, Cnt]
    after Timeout ->
            [true, Cnt]
    end,
    case IsTimeout of
        true  ->
            NewCnt;
        false ->
            receive_results(TimeoutMicroSecs, NewCnt, MaxCnt)
    end;
receive_results(_, Cnt, _) ->
    Cnt.

spawn_link_workers(Cnt, Workers) when Cnt > 0 ->
    Pid = spawn_link(fun cfg_worker/0),
    NewWorkers = queue:in(Pid, Workers),
    spawn_link_workers(Cnt - 1, NewWorkers);
spawn_link_workers(_, Workers) ->
    Workers.

%% 清空消息使用
%% ok        表示正常期望结果，消息已经清空
%% over_cnt  表示超过最大的重试次数，还没清空消息（可能还一直在接收到消息或太慢超过最大重试次数）
sub_flush_msgs(WaitTime, Cnt, MaxCnt) when Cnt =< MaxCnt ->
    {_, Len} = erlang:process_info(self(), message_queue_len),
    case Len of
        0 ->
            case receive_n_msg(1, WaitTime) of
                timeout -> ok;      %% 结束
                ok      -> sub_flush_msgs(WaitTime, Cnt, MaxCnt)
            end;
        _ ->
            case receive_n_msg(Len, WaitTime) of
                timeout -> sub_flush_msgs(WaitTime, Cnt + 1, MaxCnt);
                ok      -> sub_flush_msgs(WaitTime, Cnt, MaxCnt)
            end
    end;
sub_flush_msgs(_, _, _) ->
    over_cnt.

%% 清理N个消息
%% 返回值：
%% timeout 未收取到足够数量消息
%% ok      正常完成
receive_n_msg(N, WaitTime) when N > 0 ->
    Ret =
    receive
        _ -> ok
    after WaitTime ->
            timeout
    end,
    case Ret of
        ok ->
            receive_n_msg(N - 1, WaitTime);
        _ ->
            timeout
    end;
receive_n_msg(_, _) ->
    ok.


%% -------------------------------------------------------
%% ETS表管理配置读取需求相关内部接口
%% -------------------------------------------------------

ensure_init_ets() ->
    Pid = whereis(?PROC_ETS_TEST),
    NewPid =
    case Pid of
        undefined ->
            start_proc_ets_test();
        _ ->
            case ets:info(?TAB_ETS_TEST, size) of
                undefined ->
                    exit(Pid, kill),
                    start_proc_ets_test();
                _ ->
                    Pid
            end
    end,
    wait_start_ok(NewPid).

start_proc_ets_test() ->
    proc_lib:spawn(fun() ->
                ets:new(?TAB_ETS_TEST, [named_table, {keypos, 2}, protected, set, {read_concurrency, true}]),
                register(?PROC_ETS_TEST, self()),
                init_ets_data(),
                loop_recv_get_status()
        end).

%% 等待查询请求，回应是否初始完成
loop_recv_get_status() ->
    Ret =
    receive
        {status, FromPid} ->
            FromPid ! init_ok,
            ok;
        stop ->
            stop;
        _ ->
            ignore
    end,
    case Ret of
        stop -> ok;
        _ ->
            loop_recv_get_status()
    end.

%% 等待初始完成，如果超时会打印错误
wait_start_ok(Pid) ->
    Pid ! {status, self()},
    sub_wait_start_ok(Pid).

sub_wait_start_ok(Pid) ->
    Ret =
    receive
        init_ok ->
            ok;
        _ ->
            ignore
    after 20000 ->
            io:format("wait init timeout~n", []),
            over
    end,
    case Ret of
        ignore ->
            sub_wait_start_ok(Pid);
        _ ->
            Ret
    end.

init_ets_data() ->
    %% 暂时data_mon未做修改，仍保留旧格式，
    %% 做测试直接指定id范围来检测和插入数据，
    %% 把所有数据load进来，尽量确保数据量进行测试
    %% 如果实际项目采用此方案，需要修改data_mon配置，
    %% 将id范围作为配置的一部分，便于加载
    init_ets_data_2(        1, 1003),
    init_ets_data_2(     9001, 659999),
    init_ets_data_2(  1001000, 42010012),
    init_ets_data_2( 51550001, 60000005),
    init_ets_data_2( 70010001, 75000101),
    init_ets_data_2( 80010001, 80010107),
    init_ets_data_2(100000001, 100300003),
    init_ets_data_2(400010001, 400080100),
    init_ets_data_2(620020001, 620030001),
    ok.

init_ets_data_2(MinId, MaxId) when MinId =< MaxId ->
    case data_mon:get(MinId) of
        [] -> ignore;
        MonConfig -> ets:insert(?TAB_ETS_TEST, MonConfig)
    end,
    init_ets_data_2(MinId + 1, MaxId);
init_ets_data_2(_, _) ->
    ok.

