%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2016-2023 Marc Worrell
%% @doc Administrate all compiled templates and compilers in flight.
%% @end
%% Copyright 2016-2023 Marc Worrell
%%
%% 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(template_compiler_admin).
-author('Marc Worrell <marc@worrell.nl>').
-behaviour(gen_server).
-export([
lookup/3,
flush/0,
flush_file/1,
flush_context_name/1
]).
-export([
start_link/0,
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
code_change/3,
terminate/2
]).
-include_lib("kernel/include/logger.hrl").
-type gen_server_from() :: {pid(),term()}.
-record(tpl, {
key :: template_compiler:template_key(),
module :: atom()
}).
-record(state, {
compiled :: ets:tab(),
compiling = [] :: list({reference(), template_compiler:template_key(), gen_server_from()}),
waiting = [] :: list({template_compiler:template_key(), gen_server_from()}),
filename_keys = [] :: list({file:filename_all(), template_compiler:template_key()})
}).
%%% ---------------------- API ----------------------
%% @doc Find a template, start a compilation if not found
-spec lookup(file:filename_all(), template_compiler:options(), any()) -> {ok, atom()} | {error, term()}.
lookup(Filename, Options, Context) ->
Runtime = template_compiler:get_option(runtime, Options),
ContextName = Runtime:get_context_name(Context),
TplKey = {ContextName, Runtime, Filename},
case ets:lookup(?MODULE, TplKey) of
[#tpl{module=Module}] ->
case Runtime:is_modified(Filename, Module:mtime(), Context) of
true -> compile_file(Filename, TplKey, Options, Context);
false -> {ok, Module}
end;
[] ->
compile_file(Filename, TplKey, Options, Context)
end.
compile_file(Filename, TplKey, Options, Context) ->
case gen_server:call(?MODULE, {compile_request, TplKey, Filename}, infinity) of
{ok, {compile, TplKey}} ->
% Unknown, compile the template
Result = try
template_compiler:compile_file(Filename, Options, Context)
catch
What:Error:Stack ->
% io:format("Error compiling template ~p: ~p:~n~p at~n ~p~n",
% [Filename, What, Error, Stack]),
?LOG_ERROR(#{
text => <<"Error compiling template">>,
template => Filename,
result => What,
reason => Error,
stack => Stack
}),
{error, Error}
end,
ok = gen_server:call(?MODULE, {compile_done, Result, TplKey}, infinity),
Result;
{ok, Module} when is_atom(Module) ->
{ok, Module};
{error, _} = Error ->
Error
end.
%% @doc Flush all template mappings
-spec flush() -> ok.
flush() ->
gen_server:cast(?MODULE, flush).
%% @doc Ping that a template has been changed
-spec flush_file(file:filename_all()) -> ok.
flush_file(Filename) ->
gen_server:cast(?MODULE, {flush_file, Filename}).
%% @doc Ping that a context has changed
-spec flush_context_name(ContextName::term()) -> ok.
flush_context_name(ContextName) ->
gen_server:cast(?MODULE, {flush_context_name, ContextName}).
-spec start_link() -> {ok, pid()} | {error, any()}.
start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
%%% ---------------------- Callbacks ----------------------
init([]) ->
Compiled = ets:new(?MODULE, [set, {keypos, #tpl.key}, named_table, protected]),
{ok, #state{
compiled = Compiled,
compiling = [],
waiting = [],
filename_keys = []
}}.
handle_call({compile_request, TplKey, Filename}, From, State) ->
case is_compiling(TplKey, State) of
true ->
{noreply, wait_for_compile(TplKey, From, State)};
false ->
FTpl = {Filename, TplKey},
State1 = case lists:member(FTpl, State#state.filename_keys) of
false ->
State#state{
filename_keys=[ FTpl | State#state.filename_keys ]
};
true ->
State
end,
{noreply, start_compile(TplKey, From, State1)}
end;
handle_call({compile_done, Result, TplKey}, _From, State) ->
State1 = case lists:keytake(TplKey, 2, State#state.compiling) of
{value, {MRef, _TplKey, _CFrom}, Compiling1} ->
erlang:demonitor(MRef),
State#state{compiling=Compiling1};
false ->
State
end,
case Result of
{ok, Module} ->
ets:insert(?MODULE, #tpl{key=TplKey, module=Module});
{error, _} ->
ok
end,
{Waiters, State2} = split_waiters(TplKey, State1),
lists:foreach(
fun({_TplKey, Waiter}) ->
gen_server:reply(Waiter, Result)
end,
Waiters),
{reply, ok, State2};
handle_call(Msg, _From, State) ->
{stop, {unknown_call, Msg}, State}.
handle_cast(flush, State) ->
true = ets:delete_all_objects(?MODULE),
State1 = State#state{
filename_keys = []
},
{noreply, State1};
handle_cast({flush_file, Filename}, State) ->
{ChangedKeys, FnKeys} = lists:partition(
fun ({F,_}) ->
F =:= Filename
end,
State#state.filename_keys),
lists:foreach(
fun({_,TplKey}) ->
ets:delete(?MODULE, TplKey)
end,
ChangedKeys),
{noreply, State#state{filename_keys=FnKeys}};
handle_cast({flush_context_name, ContextName}, State) ->
Matched = ets:foldl(
fun
(#tpl{key={Ctx, _, _}} = Tpl, Acc) when Ctx =:= ContextName ->
[Tpl|Acc];
(_, Acc) ->
Acc
end,
[],
?MODULE),
% TODO: add module to list of modules that might be purged
lists:foreach(
fun(#tpl{key=Key}) ->
ets:delete(?MODULE, Key)
end,
Matched),
FilenameKeys = lists:filter(
fun({_Fn, K}) ->
K =/= ContextName
end,
State#state.filename_keys),
{noreply, State#state{filename_keys=FilenameKeys}};
handle_cast(Msg, State) ->
{stop, {unknown_cast, Msg}, State}.
handle_info({'DOWN', MRef, process, _Pid, Reason}, State) ->
case lists:keytake(MRef, 1, State#state.compiling) of
{value, {_MRef, TplKey, _From}, Compiling1} ->
?LOG_ERROR(#{
text => <<"Compiler process down; restarting with other waiter">>,
tpl_key => TplKey,
result => error,
reason => Reason
}),
{noreply, restart_compile(TplKey, State#state{compiling=Compiling1})};
false ->
{noreply, State}
end;
handle_info(Msg, State) ->
{stop, {unknown_info, Msg}, State}.
code_change(_FromVersion, State, _Extra) ->
{ok, State}.
terminate(_Reason, _State) ->
ok.
%%% ---------------------- Internal ----------------------
is_compiling(TplKey, State) ->
lists:keymember(TplKey, 1, State#state.waiting)
orelse lists:keymember(TplKey, 2, State#state.compiling).
wait_for_compile(TplKey, From, State) ->
State#state{waiting=[{TplKey,From} | State#state.waiting]}.
start_compile(TplKey, From, State) ->
State1 = wait_for_compile(TplKey, From, State),
restart_compile(TplKey, State1).
restart_compile(TplKey, State) ->
case lists:keytake(TplKey, 1, State#state.waiting) of
{value, {_,{Pid, _} = From}, Waiting} ->
MRef = erlang:monitor(process, Pid),
gen_server:reply(From, {ok, {compile, TplKey}}),
State#state{waiting=Waiting, compiling=[{MRef,TplKey,From}|State#state.compiling]};
false ->
% Drop this request, no one is waiting anymore
State
end.
split_waiters(TplKey, State) ->
{Ready,Waiting} = lists:partition(
fun({K,_From}) ->
K =:= TplKey
end,
State#state.waiting),
{Ready, State#state{waiting=Waiting}}.