Current section

Files

Jump to
brod src brod_producer_buffer.erl
Raw

src/brod_producer_buffer.erl

%%%
%%% Copyright (c) 2015-2106, Klarna AB
%%%
%%% 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.
%%%
%%%=============================================================================
%%% @doc
%%% @copyright 2015-2016 Klarna AB
%%% @end
%%% ============================================================================
%% @private
-module(brod_producer_buffer).
-export([ new/5
, add/4
, ack/2
, nack/3
, nack_all/2
, maybe_send/2
]).
-export([ is_empty/1
]).
-export_type([buf/0]).
-include("brod_int.hrl").
%% keep data in fun() to avoid huge log dumps in case of crash etc.
-type data() :: fun(() -> {key(), value()}).
-record(req,
{ call_ref :: brod_call_ref()
, data :: data()
, bytes :: non_neg_integer()
, failures :: non_neg_integer() %% the number of failed attempts
}).
-type send_fun() :: fun((pid(), [{key(), value()}]) ->
ok |
{ok, corr_id()} |
{error, any()}).
-define(ERR_FUN, fun() -> erlang:error(bad_init) end).
-define(EMPTY_QUEUE, {[],[]}).
-record(buf,
{ buffer_limit = 1 :: pos_integer()
, onwire_limit = 1 :: pos_integer()
, max_batch_size = 1 :: pos_integer()
, max_retries = 0 :: integer()
, send_fun = ?ERR_FUN :: send_fun()
, buffer_count = 0 :: non_neg_integer()
, onwire_count = 0 :: non_neg_integer()
, pending = ?EMPTY_QUEUE :: queue:queue(#req{})
, buffer = ?EMPTY_QUEUE :: queue:queue(#req{})
, onwire = [] :: [{corr_id(), [#req{}]}]
}).
-opaque buf() :: #buf{}.
%%%_* APIs =====================================================================
%% @doc Create a new buffer
%% For more details: @see brod_producer:start_link/4
%% @end
-spec new(pos_integer(), pos_integer(),
pos_integer(), integer(), send_fun()) -> buf().
new(BufferLimit, OnWireLimit, MaxBatchSize, MaxRetry, SendFun) ->
true = (BufferLimit > 0), %% assert
true = (OnWireLimit > 0), %% assert
true = (MaxBatchSize > 0), %% assert
?EMPTY_QUEUE = queue:new(), %% assert
#buf{ buffer_limit = BufferLimit
, onwire_limit = OnWireLimit
, max_batch_size = MaxBatchSize
, max_retries = MaxRetry
, send_fun = SendFun
}.
%% @doc Buffer a produce request.
%% Respond to caller immediately if the buffer limit is not yet reached.
%% @end
-spec add(buf(), brod_call_ref(), key(), value()) -> {ok, buf()}.
add(#buf{pending = Pending} = Buf, CallRef, Key, Value) ->
Req = #req{ call_ref = CallRef
, data = fun() -> {Key, Value} end
, bytes = data_size(Key) + data_size(Value)
, failures = 0
},
maybe_buffer(Buf#buf{pending = queue:in(Req, Pending)}).
%% @doc Maybe (if there is any produce requests buffered) send the produce
%% request to kafka. In case a request has been tried for more than limited
%% times, and 'exit' exception is raised.
%% @end
-spec maybe_send(buf(), pid()) -> {ok, buf()} | {retry, buf()}.
maybe_send(#buf{} = Buf, SockPid) ->
case take_reqs_to_send(Buf) of
{[], NewBuf} -> {ok, NewBuf};
{Reqs, NewBuf} -> do_send(Reqs, NewBuf, SockPid)
end.
%% @doc Reply 'acked' to callers.
-spec ack(buf(), corr_id()) -> {ok, buf()} | {error, ignored}.
ack(#buf{ onwire_count = OnWireCount
, onwire = [{CorrId, Reqs} | Rest]
} = Buf, CorrId) ->
ok = lists:foreach(fun reply_acked/1, Reqs),
{ok, Buf#buf{ onwire_count = OnWireCount - 1
, onwire = Rest
}};
ack(#buf{onwire = OnWire}, CorrIdReceived) ->
%% unkonwn corr-id, ignore
true = assert_corr_id(OnWire, CorrIdReceived),
{error, ignored}.
%% @doc 'Negative' ack, put all sent requests back to the head of buffer.
%% An 'exit' exception is raised if any of the negative-acked requests
%% reached maximum retry limit.
%% Unknown corr-id:s are ignored.
%% @end
-spec nack(buf(), corr_id(), any()) -> {ok, buf()} | {error, ignored}.
nack(#buf{onwire = [{CorrId, _Reqs} | _]} = Buf, CorrId, Reason) ->
nack_all(Buf, Reason);
nack(#buf{onwire = OnWire}, CorrIdReceived, _Reason) ->
true = assert_corr_id(OnWire, CorrIdReceived),
%% unknown corr-id, ignore.
{error, ignored}.
%% @doc 'Negative' ack, put all sent requests back to the head of buffer.
%% An 'exit' exception is raised if any of the negative-acked requests
%% reached maximum retry limit.
%% @end
-spec nack_all(buf(), any()) -> {ok, buf()}.
nack_all(#buf{onwire = OnWire} = Buf, Reason) ->
AllOnWireReqs = lists:map(fun({_CorrId, Reqs}) -> Reqs end, OnWire),
NewBuf = Buf#buf{ onwire_count = 0
, onwire = []
},
{ok, rebuffer_or_crash(lists:append(AllOnWireReqs), NewBuf, Reason)}.
%% @doc Return true if there is no message pending,
%% buffered or waiting for ack.
%% @end
is_empty(#buf{ pending = ?EMPTY_QUEUE
, buffer = ?EMPTY_QUEUE
, onwire = []
}) -> true;
is_empty(#buf{}) -> false.
%%%_* Internal functions =======================================================
%% @private This is a validation on the received correlation IDs for produce
%% responses, the assumption made in brod implementation is that kafka broker
%% guarantees the produce responses are replied in the order the corresponding
%% produce requests were received from clients.
%% @end
-spec assert_corr_id([{corr_id(), [#req{}]}], corr_id()) -> true.
assert_corr_id(_OnWireRequests = [], _CorrIdReceived) ->
true;
assert_corr_id([{CorrId, _Req} | _], CorrIdReceived) ->
case is_later_corr_id(CorrId, CorrIdReceived) of
true -> exit({bad_order, CorrId, CorrIdReceived});
false -> true
end.
%% @private Compare two corr-ids, return true if ID-2 is considered a 'later'
%% one comparing to ID1.
%% Assuming that no clients would send up to 2^26 messages asynchronously.
%% @end
-spec is_later_corr_id(corr_id(), corr_id()) -> boolean().
is_later_corr_id(Id1, Id2) ->
Diff = abs(Id1 - Id2),
case Diff < (?MAX_CORR_ID div 2) of
true -> Id1 < Id2;
false -> Id1 > Id2
end.
%% @private Get the failure count of the first item in the buffer
-spec buffer_head_failures(queue:queue(#req{})) -> integer().
buffer_head_failures(Buffer) ->
case queue:peek(Buffer) of
{value, #req{failures = F}} -> F;
empty -> 0
end.
-spec take_reqs_to_send(buf()) -> {[#req{}], buf()}.
take_reqs_to_send(#buf{ onwire_count = OnWireCount
, onwire_limit = OnWireLimit
} = Buf) when OnWireCount >= OnWireLimit ->
{[], Buf};
take_reqs_to_send(#buf{ buffer = Buffer
, buffer_count = BufferCount
, onwire_count = OnWireCount
} = Buf) ->
BufferHeadFailures = buffer_head_failures(Buffer),
case {BufferHeadFailures > 0, OnWireCount > 0} of
{true, true} ->
%% do not retry if there is one on wire
{[], Buf};
{true, false} ->
%% always retry only one message at a time, i.e. no batch
{{value, Req}, Reqs} = queue:out(Buffer),
NewBuf = Buf#buf{ buffer = Reqs
, buffer_count = BufferCount - 1
},
{[Req], NewBuf};
{false, _} ->
take_reqs_to_send(Buf, _Acc = [], _AccBytes = 0)
end.
-spec take_reqs_to_send(buf(), [#req{}], integer()) -> {[#req{}], buf()}.
take_reqs_to_send(#buf{ pending = ?EMPTY_QUEUE
, buffer = ?EMPTY_QUEUE
} = Buf, Acc, _AccBytes) ->
%% no more requests in buffer & pending
{lists:reverse(Acc), Buf};
take_reqs_to_send(#buf{buffer = ?EMPTY_QUEUE} = Buf, Acc, AccBytes) ->
%% no more requests in buffer, take more from pending
{ok, NewBuf} = maybe_buffer(Buf),
take_reqs_to_send(NewBuf, Acc, AccBytes);
take_reqs_to_send(#buf{max_batch_size = MaxBatchSize} = Buf, Acc, AccBytes)
when AccBytes >= MaxBatchSize ->
%% reached max bytes in one message set
{lists:reverse(Acc), Buf};
take_reqs_to_send(#buf{ buffer_count = BufferCount
, buffer = Buffer
} = Buf, _Acc = [], _AccBytes = 0) ->
%% always send at least one message one time regardless of size
{{value, Req}, Rest} = queue:out(Buffer),
NewBuf = Buf#buf{ buffer_count = BufferCount - 1
, buffer = Rest
},
take_reqs_to_send(NewBuf, [Req], Req#req.bytes);
take_reqs_to_send(#buf{ buffer_count = BufferCount
, buffer = Buffer
} = Buf, Acc, AccBytes) ->
{{value, Req}, Rest} = queue:out(Buffer),
NewBuf = Buf#buf{ buffer_count = BufferCount - 1
, buffer = Rest
},
take_reqs_to_send(NewBuf, [Req | Acc], AccBytes + Req#req.bytes).
%% @private Send produce request to kafka.
-spec do_send([#req{}], buf(), pid()) -> {ok, buf()} | {retry, buf()}.
do_send(Reqs, #buf{ onwire_count = OnWireCount
, onwire = OnWire
, send_fun = SendFun
} = Buf, SockPid) ->
MessageSet = lists:map(fun(#req{data = F}) -> F() end, Reqs),
case SendFun(SockPid, MessageSet) of
ok ->
%% fire and forget
ok = lists:foreach(fun reply_acked/1, Reqs),
{ok, Buf};
{ok, CorrId} ->
{ok, Buf#buf{ onwire_count = OnWireCount + 1
, onwire = OnWire ++ [{CorrId, Reqs}]
}};
{error, Reason} ->
NewBuf = rebuffer_or_crash(Reqs, Buf, Reason),
{retry, NewBuf}
end.
%% @private Put the produce requests back to buffer.
%% raise an 'exit' exception if the first request to send has reached
%% retry limit
%% @end
-spec rebuffer_or_crash([#req{}], buf(), any()) -> buf() | no_return().
rebuffer_or_crash([#req{failures = Failures} | _],
#buf{max_retries = MaxRetries}, Reason)
when MaxRetries >= 0 andalso Failures >= MaxRetries ->
exit({reached_max_retries, Reason});
rebuffer_or_crash(Reqs0, #buf{ buffer = Buffer
, buffer_count = BufferCount
} = Buf, _Reason) ->
Reqs = lists:map(fun(#req{failures = Failures} = Req) ->
Req#req{failures = Failures + 1}
end, Reqs0),
NewBuffer = lists:foldr(fun (Req, AccBuffer) ->
queue:in_r(Req, AccBuffer)
end, Buffer, Reqs),
Buf#buf{ buffer = NewBuffer
, buffer_count = length(Reqs) + BufferCount
}.
%% @private Take pending requests into buffer and reply 'buffered' to caller.
-spec maybe_buffer(buf()) -> {ok, buf()}.
maybe_buffer(#buf{ pending = ?EMPTY_QUEUE } = Buf) ->
{ok, Buf};
maybe_buffer(#buf{ buffer_limit = BufferLimit
, buffer_count = BufferCount
, pending = Pending
, buffer = Buffer
} = Buf) when BufferCount < BufferLimit ->
{{value, Req}, NewPending} = queue:out(Pending),
ok = reply_buffered(Req),
NewBuf = Buf#buf{ buffer_count = BufferCount + 1
, pending = NewPending
, buffer = queue:in(Req, Buffer)
},
maybe_buffer(NewBuf);
maybe_buffer(#buf{} = Buf) ->
{ok, Buf}.
-spec reply_buffered(#req{}) -> ok.
reply_buffered(#req{call_ref = CallRef}) ->
Reply = #brod_produce_reply{ call_ref = CallRef
, result = brod_produce_req_buffered
},
cast(CallRef#brod_call_ref.caller, Reply).
-spec reply_acked(#req{}) -> ok.
reply_acked(#req{call_ref = CallRef}) ->
Reply = #brod_produce_reply{ call_ref = CallRef
, result = brod_produce_req_acked
},
cast(CallRef#brod_call_ref.caller, Reply).
cast(Pid, Msg) ->
try
Pid ! Msg,
ok
catch _ : _ ->
ok
end.
-spec data_size(key() | value()) -> non_neg_integer().
data_size(Data) -> brod_utils:bytes(Data).
%%%_* Tests ====================================================================
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
cast_test() ->
Ref = make_ref(),
ok = cast(self(), Ref),
receive Ref -> ok
end,
ok = cast(?undef, Ref).
assert_corr_id_test() ->
{error, ignored} = ack(#buf{}, 0),
{error, ignored} = nack(#buf{}, 0, ignored),
{error, ignored} = ack(#buf{onwire = [{1, req}]}, 0),
{error, ignored} = nack(#buf{onwire = [{1, req}]}, 0, ignored),
{error, ignored} = ack(#buf{onwire = [{1, req}]}, ?MAX_CORR_ID),
?assertException(exit, {bad_order, 0, 1},
ack(#buf{onwire = [{0, req}]}, 1)),
?assertException(exit, {bad_order, ?MAX_CORR_ID, 0},
ack(#buf{onwire = [{?MAX_CORR_ID, req}]}, 0)),
ok.
-endif. % TEST
%%%_* Emacs ====================================================================
%%% Local Variables:
%%% allout-layout: t
%%% erlang-indent-level: 2
%%% End: