Current section
Files
Jump to
Current section
Files
src/wolff_pendack.erl
%% Copyright (c) 2024 EMQ Technologies Co., Ltd. All Rights Reserved.
%%
%% 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 Implement a data structure to hold pending acks towards `send' or `cast'
%% callers.
%% The pending acks are stored in a queue,
%% each item in the queue is a pair of call IDs and callback.
%% The call ID is a monotonically increasing integer, starting from current
%% time in microseconds.
-module(wolff_pendack).
-export([new/0, count/1, insert/2, take/2]).
-export_type([acks/0]).
-type call_id() :: pos_integer().
-type key() :: call_id() | {call_id(), call_id()}.
-type cb() :: term().
-opaque acks() :: #{next_id := integer(),
cbs := queue:queue({key(), cb()})}.
%% @doc Initialize a new data structure.
new() ->
%% use a timestamp for call ID base so the items recovered from disk
%% will not be possible to clash with newer generation call after
%% the process crashed or node restarted.
Now = erlang:system_time(microsecond),
#{next_id => Now,
cbs => queue:new()
}.
%% @doc count the total number of pending acks.
-spec count(acks()) -> non_neg_integer().
count(#{cbs := Cbs}) ->
sum(queue:to_list(Cbs), 0).
sum([], Acc) ->
Acc;
sum([{CallId, _} | Rest], Acc) when is_integer(CallId) ->
sum(Rest, Acc + 1);
sum([{{MinCallId, MaxCallId}, _} | Rest], Acc) ->
sum(Rest, Acc + MaxCallId - MinCallId + 1).
%% @doc insert a callback into the data structure.
-spec insert(acks(), cb()) -> {call_id(), acks()}.
insert(#{next_id := Id, cbs := Cbs} = X, Cb) ->
NewCbs = insert_cb(Cbs, Id, Cb),
{Id, X#{next_id => Id + 1, cbs => NewCbs}}.
insert_cb(Cbs, Id, Cb) ->
case queue:out_r(Cbs) of
{empty, _} ->
queue:in({Id, Cb}, Cbs);
{{value, {Key1, Cb1}}, Cbs1} ->
insert_cb1(Cbs1, Key1, Cb1, Id, Cb)
end.
%% If the callback is identical to the previous one, then just update the
%% call ID range.
%% Otherwise, insert the new callback.
insert_cb1(Cbs, Key, Cb, Id, Cb1) when Cb =:= Cb1 ->
Key1 = expand_id(Key, Id),
queue:in({Key1, Cb1}, Cbs);
insert_cb1(Cbs, Key, Cb, Id, Cb1) ->
queue:in({Id, Cb1}, queue:in({Key, Cb}, Cbs)).
%% If the ID is a single integer, then expand it to a range.
expand_id(Id0, Id) when is_integer(Id0) ->
Id =:= Id0 + 1 orelse error({unexpected_id, Id0, Id}),
expand_id({Id0, Id0}, Id);
expand_id({MinId, MaxId}, Id) ->
Id =:= MaxId + 1 orelse error({unexpected_id, {MinId, MaxId}, Id}),
{MinId, Id}.
%% @doc Take the callback for a given call ID.
%% The ID is expected to be the oldest in the queue.
%% Return the callback and the updated data structure.
-spec take(acks(), call_id()) -> {ok, cb(), acks()} | false.
take(#{cbs := Cbs} = X, Id) ->
case take1(Cbs, Id) of
false ->
%% stale ack
false;
{ok, Cb, Cbs1} ->
{ok, Cb, X#{cbs => Cbs1}}
end.
take1(Cbs0, Id) ->
case queue:out(Cbs0) of
{empty, _} ->
false;
{{value, {Key, Cb}}, Cbs} ->
take2(Cbs, Key, Cb, Id)
end.
take2(Cbs, Id0, Cb, Id) when is_integer(Id0) ->
take2(Cbs, {Id0, Id0}, Cb, Id);
take2(_Cbs, {MinId, _MaxId}, _Cb, Id) when Id < MinId ->
%% stale ack
false;
take2(Cbs, {MinId, MaxId}, Cb, Id) when Id =:= MinId ->
%% ack the oldest item
case MaxId =:= MinId of
true ->
{ok, Cb, Cbs};
false ->
{ok, Cb, queue:in_r({{Id + 1, MaxId}, Cb}, Cbs)}
end;
take2(_Cbs, {MinId, MaxId}, _Cb, Id) ->
error(#{cause => unexpected_id, min => MinId, max => MaxId, got => Id}).