Skip to main content

src/barrel_hlc.erl

%%%-------------------------------------------------------------------
%%% @doc Hybrid Logical Clock utilities for barrel_docdb
%%%
%%% HLC timestamps are used for ordering changes across distributed nodes.
%%% They combine physical wall clock time with a logical counter to ensure
%%% causality while maintaining a close relationship to real time.
%%%
%%% Format: {WallTime, Logical} where WallTime is in milliseconds and
%%% Logical is a counter that increments when events happen in the
%%% physical clock's future.
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_hlc).

-compile({no_auto_import, [now/0]}).

-include_lib("hlc/include/hlc.hrl").

%% Global HLC operations (use the node-global clock)
-export([
    now/0,
    update/1,
    timestamp/0,
    get_hlc/0,
    sync_hlc/1,
    new_hlc/0,
    maybe_sync_from_header/1
]).

%% Instance-based operations (for testing or custom clocks)
-export([
    start_link/0,
    start_link/1,
    stop/1,
    now/1,
    update/2,
    timestamp/1
]).

%% Encoding/Decoding
-export([
    encode/1,
    decode/1,
    to_binary/1,
    from_binary/1,
    to_string/1,
    from_string/1
]).

%% Comparison
-export([
    compare/2,
    less/2,
    equal/2
]).

%% Constants
-export([
    min/0,
    max/0
]).

%% Accessor functions
-export([
    wall_time/1
]).

%% Types
-export_type([
    clock/0,
    timestamp/0
]).

%%====================================================================
%% Types
%%====================================================================

-type clock() :: hlc:clock().
%% HLC clock process reference.

-type timestamp() :: #timestamp{}.
%% HLC timestamp with wall_time and logical components.

%% Global clock name (started by barrel_docdb_sup)
-define(GLOBAL_CLOCK, barrel_hlc_clock).

%%====================================================================
%% Global HLC Operations
%%====================================================================
%% These functions operate on the node-global HLC clock that is started
%% by the barrel_docdb supervisor. Use these for distributed ordering.

%% @doc Get the current global HLC timestamp without advancing the clock.
-spec get_hlc() -> timestamp().
get_hlc() ->
    timestamp().

%% @doc Synchronize with a remote HLC timestamp.
%% Call this when receiving data from another node to maintain causality.
%% Returns {ok, NewTimestamp} or {error, clock_skew}.
-spec sync_hlc(timestamp()) -> {ok, timestamp()} | {error, clock_skew}.
sync_hlc(RemoteTS) ->
    update(RemoteTS).

%% @doc Generate a new global HLC timestamp, advancing the clock.
%% Use this when generating a new event that needs to be ordered.
-spec new_hlc() -> timestamp().
new_hlc() ->
    now().

%% @doc Sync HLC from a base64-encoded HTTP header value.
%% Silently ignores undefined or invalid values.
-spec maybe_sync_from_header(binary() | undefined) -> ok.
maybe_sync_from_header(undefined) ->
    ok;
maybe_sync_from_header(HlcBase64) ->
    barrel_lib:safe(fun() ->
        HlcBin = base64:decode(HlcBase64),
        Hlc = decode(HlcBin),
        _ = sync_hlc(Hlc),
        ok
    end, ok).

%% @doc Generate a new timestamp for a local event using global clock.
-spec now() -> timestamp().
now() ->
    hlc:now(?GLOBAL_CLOCK).

%% @doc Update the global clock with a remote timestamp.
%% Rejects non-timestamp input at the API boundary so a misuse cannot
%% crash the global `barrel_hlc_clock' gen_server.
-spec update(timestamp()) -> {ok, timestamp()} | {error, clock_skew}.
update(#timestamp{} = RemoteTS) ->
    case hlc:update(?GLOBAL_CLOCK, RemoteTS) of
        {ok, NewTS} ->
            {ok, NewTS};
        {timeahead, _} ->
            {error, clock_skew}
    end.

%% @doc Get the current global timestamp without advancing the clock.
-spec timestamp() -> timestamp().
timestamp() ->
    hlc:timestamp(?GLOBAL_CLOCK).

%%====================================================================
%% Instance-based Clock Management
%%====================================================================
%% These functions are for testing or when you need a custom clock
%% instance separate from the global node clock.

%% @doc Start a new HLC clock with default settings.
%% Uses physical system clock and no offset limit.
-spec start_link() -> {ok, clock()}.
start_link() ->
    hlc:start_link().

%% @doc Start a new HLC clock with maximum offset limit.
%% MaxOffset is the maximum allowed clock skew in milliseconds.
%% A value of 0 disables offset checking.
-spec start_link(MaxOffset :: non_neg_integer()) -> {ok, clock()}.
start_link(MaxOffset) ->
    hlc:start_link(fun hlc:physical_clock/0, MaxOffset).

%% @doc Stop the HLC clock.
-spec stop(clock()) -> ok | {error, term()}.
stop(Clock) ->
    hlc:stop(Clock).

%% @doc Generate a new timestamp using a specific clock instance.
-spec now(clock()) -> timestamp().
now(Clock) ->
    hlc:now(Clock).

%% @doc Update a specific clock with a remote timestamp.
-spec update(clock(), timestamp()) -> {ok, timestamp()} | {error, clock_skew}.
update(Clock, RemoteTS) ->
    case hlc:update(Clock, RemoteTS) of
        {ok, NewTS} ->
            {ok, NewTS};
        {timeahead, _} ->
            {error, clock_skew}
    end.

%% @doc Get the current timestamp from a specific clock without advancing it.
-spec timestamp(clock()) -> timestamp().
timestamp(Clock) ->
    hlc:timestamp(Clock).

%%====================================================================
%% Encoding/Decoding
%%====================================================================

%% @doc Encode timestamp to binary for storage.
%% Uses big-endian encoding to maintain lexicographic sort order.
%% Format: 12-byte binary with WallTime (64-bit) and Logical (32-bit).
-spec encode(timestamp()) -> binary().
encode(#timestamp{wall_time = WallTime, logical = Logical}) ->
    <<WallTime:64/big-unsigned, Logical:32/big-unsigned>>.

%% @doc Decode binary to timestamp.
-spec decode(binary()) -> timestamp().
decode(<<WallTime:64/big-unsigned, Logical:32/big-unsigned>>) ->
    #timestamp{wall_time = WallTime, logical = Logical};
decode(_) ->
    erlang:error(badarg).

%% @doc Alias for encode/1 for consistency with other modules.
-spec to_binary(timestamp()) -> binary().
to_binary(TS) ->
    encode(TS).

%% @doc Alias for decode/1 for consistency with other modules.
-spec from_binary(binary()) -> timestamp().
from_binary(Bin) ->
    decode(Bin).

%% @doc Convert timestamp to human-readable string.
%% Format: "WallTime:Logical" (e.g., "1609459200000:42")
-spec to_string(timestamp()) -> binary().
to_string(#timestamp{wall_time = WallTime, logical = Logical}) ->
    WallBin = integer_to_binary(WallTime),
    LogicalBin = integer_to_binary(Logical),
    <<WallBin/binary, ":", LogicalBin/binary>>.

%% @doc Parse timestamp from string.
-spec from_string(binary()) -> timestamp() | {error, invalid_timestamp}.
from_string(Bin) when is_binary(Bin) ->
    case binary:split(Bin, <<":">>) of
        [WallBin, LogicalBin] ->
            barrel_lib:safe(fun() ->
                WallTime = binary_to_integer(WallBin),
                Logical = binary_to_integer(LogicalBin),
                #timestamp{wall_time = WallTime, logical = Logical}
            end, {error, invalid_timestamp});
        _ ->
            {error, invalid_timestamp}
    end;
from_string(_) ->
    {error, invalid_timestamp}.

%%====================================================================
%% Comparison
%%====================================================================

%% @doc Compare two timestamps.
%% Returns `lt' if TS1 is before TS2, `eq' if equal, `gt' if TS1 is after TS2.
-spec compare(timestamp(), timestamp()) -> lt | eq | gt.
compare(#timestamp{wall_time = W1, logical = L1},
        #timestamp{wall_time = W2, logical = L2}) ->
    if
        W1 < W2 -> lt;
        W1 > W2 -> gt;
        L1 < L2 -> lt;
        L1 > L2 -> gt;
        true -> eq
    end.

%% @doc Check if TS1 is less than TS2.
-spec less(timestamp(), timestamp()) -> boolean().
less(TS1, TS2) ->
    hlc:less(TS1, TS2).

%% @doc Check if two timestamps are equal.
-spec equal(timestamp(), timestamp()) -> boolean().
equal(TS1, TS2) ->
    hlc:equal(TS1, TS2).

%%====================================================================
%% Constants
%%====================================================================

%% @doc Minimum timestamp value (for range scans).
-spec min() -> timestamp().
min() ->
    #timestamp{wall_time = 0, logical = 0}.

%% @doc Maximum timestamp value (for range scans).
-spec max() -> timestamp().
max() ->
    %% Use maximum values that fit in our encoding format
    #timestamp{wall_time = 16#FFFFFFFFFFFFFFFF, logical = 16#FFFFFFFF}.

%%====================================================================
%% Accessor Functions
%%====================================================================

%% @doc Extract wall_time (milliseconds since epoch) from a timestamp.
-spec wall_time(timestamp()) -> non_neg_integer().
wall_time(#timestamp{wall_time = WallTime}) ->
    WallTime.