Packages

Fault-tolerant dataflow DSL

Current section

Files

Jump to
kflow src framework kflow_loopback_consumer.erl_
Raw

src/framework/kflow_loopback_consumer.erl_

%%%===================================================================
%%% @copyright 2020 Klarna Bank AB (publ)
%%%
%%% @doc `kflow_loopback_consumer' is a source type that receives data
%%% as Erlang messages. Its primary purpose is to glue together
%%% different pipes. (Think named pipes in Unix)
%%%
%%% == Error handling ==
%%%
%%% Processes interacting with the loopback consumer are linked to the
%%%
%%% @end
%%%===================================================================
-module(kflow_loopback_consumer).
-include("kflow_int.hrl").
%% API
-export([ start_link/1
, stop/1
, feed/1
, feed/2
]).
-export_type([config/0]).
%%%===================================================================
%%% Types
%%%===================================================================
-type config() ::
#{ id := atom()
, pipe_spec := kflow:pipe()
| fun((kflow:node_spec()) -> kflow:pipe())
, feed_timeout => timeout()
, shutdown_timeout => timeout()
, idle_timeout => non_neg_integer() | undefined
}.
-record(worker,
{ pid :: pid()
, entrypoint :: kflow_pipe:entrypoint()
, id :: kflow:node_id()
}).
-type workers() :: #{brod:partition() => #worker{}}.
-record(state,
{ config :: kflow:pipe_config()
, group_id :: brod:group_id()
, workers = #{} :: workers()
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec feed(atom(), non_neg_integer()) ->
feed(Id, Partition) ->
-spec start_link(config()) -> {ok, pid()}.
start_link(Config = #{ id := Id
, group_id := GroupId
, topics := Topics
}) ->
declare_metrics(),
kflow_lib:ensure_log(Id),
ClientId = maps:get(brod_client_id, Config, ?default_brod_client),
brod_group_subscriber:start_link( ClientId
, GroupId
, Topics
, _GroupConfig = []
, consumer_config(Config)
, _DeliveryType = message_set
, ?MODULE
, Config
).
-spec stop(pid()) -> ok.
stop(Pid) ->
brod_group_subscriber:stop(Pid).
%%%===================================================================
%%% brod_group_subscriber callbacks
%%%===================================================================
init(GroupId, Config) ->
#{id := Id} = Config,
?set_process_metadata(#{domain => kflow_lib:root_node_id(Id)}),
PipeConfig = init_data(Config),
{ok, #state{ config = PipeConfig
, group_id = GroupId
}}.
handle_message(Topic, Partition, MessageSet, State0) ->
#kafka_message_set{ messages = Messages
, high_wm_offset = HighWmOffset
, topic = Topic
} = MessageSet,
#kafka_message{offset = BaseOffset} = hd(Messages),
prometheus_gauge:set( <<"kflow_kafka_offset_high_wm">>
, [Topic, Partition]
, HighWmOffset
),
State = maybe_spawn_worker(Topic, Partition, State0),
ok = dispatch_to_partition_worker(Partition, MessageSet, State),
{ok, ack_no_commit, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec dispatch_to_partition_worker( brod:partition()
, brod:message_set()
, #state{}
) -> ok.
dispatch_to_partition_worker(Partition, MessageSet, #state{workers = Workers}) ->
#worker{ entrypoint = Entrypoint
, id = Id0
} = maps:get(Partition, Workers),
Id = kflow_lib:pretty_print_node_id(Id0),
#kafka_message_set{ messages = Messages
, topic = Topic
, partition = Partition
} = MessageSet,
Route = [{Topic, Partition}],
lists:foreach( fun(#kafka_message{ offset = Offset
, key = Key
, value = Value
, headers = Headers
, ts = Ts
}) ->
Payload = #{ key => Key
, ts => Ts
, value => Value
, headers => Headers
, partition => Partition
},
Msg = #kflow_msg{ offset = Offset
, route = Route
, payload = Payload
},
prometheus_gauge:set( <<"kflow_kafka_offset_in">>
, [Topic, Partition]
, Offset
),
TBefore = erlang:monotonic_time(millisecond),
ok = Entrypoint(Msg),
TAfter = erlang:monotonic_time(millisecond),
prometheus_histogram:observe( <<"kflow_kafka_consume_time">>
, [Id]
, TAfter - TBefore
)
end
, Messages
),
ok.
-spec maybe_spawn_worker(brod:topic(), brod:partition(), #state{}) -> #state{}.
maybe_spawn_worker(Topic, Partition, State = #state{workers = Workers}) ->
case maps:get(Partition, Workers, false) of
#worker{} ->
State;
false ->
#state{ group_id = GroupId
, config = PipeConfig
} = State,
Worker = spawn_worker(GroupId, Topic, Partition, PipeConfig),
State#state{workers = Workers#{Partition => Worker}}
end.
-spec spawn_worker(brod:group_id(), brod:topic(), brod:partition(), map()) ->
#worker{}.
spawn_worker(GroupId, Topic, Partition, Config) ->
#{ pipe_spec := PipeSpec0
, id := ParentId
} = Config,
Id = ParentId ++ [list_to_atom(integer_to_list(Partition))],
CommitterNode = committer_node(self(), Topic, Partition, GroupId),
PipeConfig0 = maps:with([feed_timeout, shutdown_timeout, idle_timeout], Config),
PipeSpec = case maps:get(auto_commit, Config, true) of
true ->
%% Commit offsets of fully processed messages:
PipeSpec0 ++ [CommitterNode];
false when is_function(PipeSpec0, 1) ->
%% Let user insert OffsetCommitNode in any place:
PipeSpec0(CommitterNode);
false ->
%% Offsets are not committed at all:
PipeSpec0
end,
PipeConfig = PipeConfig0 #{ definition => PipeSpec
, id => Id
},
{ok, Pid, Entrypoint} = kflow_pipe:start_link(PipeConfig),
?tp(kflow_consumer_start, #{ topic => Topic
, partition => Partition
, id => Id
, pid => Pid
}),
#worker{pid = Pid, entrypoint = Entrypoint, id = Id}.
%% Create a kfnode that will commit kafka offsets:
-spec committer_node(pid(), brod:topic(), brod:partition(), brod:group_id()) ->
kflow:node_spec().
committer_node(ParentPid, Topic, Partition, GroupId) ->
CommitFun = fun(Offset) ->
ok = brod_group_subscriber:commit( ParentPid
, Topic
, Partition
, Offset
)
end,
NodeConfig = #{ topic => Topic
, partition => Partition
, group_id => GroupId
, commit_fun => CommitFun
},
{kflow_kafka_commit, undefined, NodeConfig}.
-spec init_data(config()) -> kflow_pipe:config().
init_data(Config = #{id := Id}) ->
%% Construct callback module config:
NodeId = kflow_lib:root_node_id(Id),
InitDataFields = [pipe_spec, auto_commit, feed_timeout, shutdown_timeout, idle_timeout],
InitData0 = maps:with(InitDataFields, Config),
InitData0 #{id => NodeId}.
-spec consumer_config(config()) -> brod:consumer_config().
consumer_config(Config) ->
ConsumerConfig = lists:keysort(1, maps:get(consumer_config, Config, [])),
DefaultConsumerConfig = [{begin_offset, earliest}],
lists:ukeymerge(1, ConsumerConfig, DefaultConsumerConfig).
-spec declare_metrics() -> ok.
declare_metrics() ->
prometheus_gauge:declare([ {name, <<"kflow_loopback_offset_in">>}
, {help, "Offset of the last message that went into the pipe"}
, {labels, [topic, partition]}
]),
prometheus_histogram:declare([ {name, <<"kflow_loopback_consume_time">>}
, {help, "Time spent pushing messages into the pipe"}
, {labels, [id]}
, {buckets, [100, 1000, 10000]}
]),
ok.