Current section

Files

Jump to
ra src ra_log_segment.erl
Raw

src/ra_log_segment.erl

%% This Source Code Form is subject to the terms of the Mozilla Public
%% License, v. 2.0. If a copy of the MPL was not distributed with this
%% file, You can obtain one at https://mozilla.org/MPL/2.0/.
%%
%% Copyright (c) 2017-2025 Broadcom. All Rights Reserved. The term Broadcom refers to Broadcom Inc. and/or its subsidiaries.
%%
%% @hidden
-module(ra_log_segment).
-export([open/1,
open/2,
append/4,
sync/1,
fold/6,
fold/7,
is_modified/1,
read_sparse/4,
read_sparse_no_checks/4,
term_query/2,
close/1,
range/1,
flush/1,
max_count/1,
data_size/1,
filename/1,
segref/1,
info/1,
info/2,
is_same_as/2,
copy/3]).
-export([dump/1,
dump/2,
dump_index/1]).
-include("ra.hrl").
-include_lib("stdlib/include/assert.hrl").
-include_lib("kernel/include/file.hrl").
-define(VERSION, 2).
-define(MAGIC, "RASG").
-define(HEADER_SIZE, 4 + (16 div 8) + (16 div 8)).
-define(INDEX_RECORD_SIZE_V1, ((2 * 64 + 3 * 32) div 8)).
-define(INDEX_RECORD_SIZE_V2, ((3 * 64 + 2 * 32) div 8)).
-define(BLOCK_SIZE, 4096). %% assumed block size
-define(READ_AHEAD_B, ?BLOCK_SIZE * 16). %% some multiple of common block sizes
-type index_record_data() :: {Term :: ra_term(), % 64 bit
Offset :: non_neg_integer(), % 32 bit
Length :: non_neg_integer(), % 32 bit
Checksum :: integer()}. % CRC32 - 32 bit
-type ra_segment_index() :: #{ra_index() => index_record_data()}.
%% Index mode for read operations:
%% - map: Parse full index into Erlang map on open (default, best for many reads)
%% - binary: Keep raw index binary, binary search on demand (best for sparse reads)
-type index_mode() :: map | binary.
-record(cfg, {version :: non_neg_integer(),
max_count = ?SEGMENT_MAX_ENTRIES :: non_neg_integer(),
max_pending = ?SEGMENT_MAX_PENDING :: non_neg_integer(),
max_size = ?SEGMENT_MAX_SIZE_B :: non_neg_integer(),
filename :: file:filename_all(),
fd :: option(file:io_device()),
index_size :: pos_integer(),
index_record_size :: pos_integer(),
access_pattern :: sequential | random,
file_advise = normal :: posix_file_advise(),
mode = append :: read | append,
index_mode = map :: index_mode(),
compute_checksums = true :: boolean()}).
-record(state,
{cfg :: #cfg{},
index_offset :: pos_integer(),
index_write_offset :: pos_integer(),
data_start :: pos_integer(),
data_offset :: pos_integer(),
data_write_offset :: pos_integer(),
%% For map mode: parsed index map
%% For binary mode: raw index binary for binary search
index = undefined :: option(ra_segment_index()) | binary(),
num_entries = 0 :: non_neg_integer(),
range :: option({ra_index(), ra_index()}),
pending_data = [] :: iodata(),
pending_index = [] :: iodata(),
pending_count = 0 :: non_neg_integer(),
cache :: undefined | {non_neg_integer(), non_neg_integer(), binary()}
}).
-type posix_file_advise() :: 'normal' | 'sequential' | 'random'
| 'no_reuse' | 'will_need' | 'dont_need'.
-type ra_log_segment_options() :: #{max_count => non_neg_integer(),
max_pending => non_neg_integer(),
max_size => non_neg_integer(),
compute_checksums => boolean(),
mode => append | read,
index_mode => index_mode(),
access_pattern => sequential | random,
file_advise => posix_file_advise()}.
-opaque state() :: #state{}.
-export_type([state/0,
posix_file_advise/0,
index_mode/0,
ra_log_segment_options/0]).
%% Index format abstraction - hides V1/V2 differences
-record(idx_fmt, {version :: 1 | 2,
record_size :: pos_integer(),
offset_size :: 32 | 64}).
-compile({inline, [idx_fmt/1,
decode_index_record/3,
encode_index_record/6]}).
-spec open(Filename :: file:filename_all()) ->
{ok, state()} | {error, term()}.
open(Filename) ->
open(Filename, #{}).
-spec open(Filename :: file:filename_all(),
Options :: ra_log_segment_options()) ->
{ok, state()} | {error, term()}.
open(Filename, Options) ->
Mode = maps:get(mode, Options, append),
FileExists = case Mode of
append ->
case prim_file:read_file_info(Filename) of
{ok, _} -> true;
_ ->
false
end;
read ->
%% assume file exists
%% it will fail to open later if it does not
true
end,
Modes = case Mode of
append ->
[read, write, raw, binary];
read ->
[read, raw, binary]
end,
case file:open(Filename, Modes) of
{ok, Fd} ->
process_file(FileExists, Mode, Filename, Fd, Options);
Err -> Err
end.
process_file(true, Mode, Filename, Fd, Options) ->
AccessPattern = maps:get(access_pattern, Options, random),
FileAdvise = maps:get(file_advise, Options, normal),
IndexMode = maps:get(index_mode, Options, map),
if FileAdvise == random andalso
Mode == read ->
Offs = maps:get(max_count, Options, ?SEGMENT_MAX_ENTRIES) * ?INDEX_RECORD_SIZE_V2,
_ = file:advise(Fd, Offs, 0, random),
ok;
true ->
ok
end,
case read_header(Fd) of
{ok, Version, MaxCount} ->
MaxPending = maps:get(max_pending, Options, ?SEGMENT_MAX_PENDING),
MaxSize = maps:get(max_size, Options, ?SEGMENT_MAX_SIZE_B),
IndexRecordSize = index_record_size(Version),
IndexSize = MaxCount * IndexRecordSize,
ComputeChecksums = maps:get(compute_checksums, Options, true),
%% Choose index recovery strategy based on mode
{NumIndexRecords, DataOffset, Range, Index, ActualIndexMode} =
case {Mode, IndexMode} of
{read, binary} ->
%% Binary mode: keep raw index binary, don't parse into map
%% Falls back to map if overwrites detected
case recover_index_binary(Fd, Version, MaxCount) of
{ok, N, DO, R, IndexBin} ->
{N, DO, R, IndexBin, binary};
{fallback_to_map, N, DO, R, IndexMap} ->
%% Overwrites detected - use map mode instead
{N, DO, R, IndexMap, map}
end;
_ ->
%% Map mode (default) or append mode
{N, DO, R, I} = recover_index(Fd, Version, MaxCount),
{N, DO, R, I, map}
end,
IndexOffset = ?HEADER_SIZE + NumIndexRecords * IndexRecordSize,
{ok, #state{cfg = #cfg{version = Version,
max_count = MaxCount,
max_pending = MaxPending,
max_size = MaxSize,
filename = Filename,
mode = Mode,
index_size = IndexSize,
index_record_size = IndexRecordSize,
access_pattern = AccessPattern,
file_advise = FileAdvise,
index_mode = ActualIndexMode,
compute_checksums = ComputeChecksums,
fd = Fd},
data_start = ?HEADER_SIZE + IndexSize,
data_offset = DataOffset,
data_write_offset = DataOffset,
index_offset = IndexOffset,
index_write_offset = IndexOffset,
range = Range,
num_entries = NumIndexRecords,
cache = undefined,
% we don't need an index in memory in append mode
index = case Mode of
read -> Index;
append -> undefined
end}};
Err ->
Err
end;
process_file(false, Mode, Filename, Fd, Options) ->
MaxCount = maps:get(max_count, Options, ?SEGMENT_MAX_ENTRIES),
MaxPending = maps:get(max_pending, Options, ?SEGMENT_MAX_PENDING),
MaxSize = maps:get(max_size, Options, ?SEGMENT_MAX_SIZE_B),
ComputeChecksums = maps:get(compute_checksums, Options, true),
IndexSize = MaxCount * ?INDEX_RECORD_SIZE_V2,
ok = write_header(MaxCount, Fd),
FileAdvise = maps:get(file_advise, Options, dont_need),
IndexMode = maps:get(index_mode, Options, map),
{ok, #state{cfg = #cfg{version = ?VERSION,
max_count = MaxCount,
max_pending = MaxPending,
max_size = MaxSize,
filename = Filename,
mode = Mode,
index_size = IndexSize,
index_record_size = ?INDEX_RECORD_SIZE_V2,
fd = Fd,
compute_checksums = ComputeChecksums,
file_advise = FileAdvise,
index_mode = IndexMode,
access_pattern = random},
index_write_offset = ?HEADER_SIZE,
index_offset = ?HEADER_SIZE,
data_start = ?HEADER_SIZE + IndexSize,
data_offset = ?HEADER_SIZE + IndexSize,
data_write_offset = ?HEADER_SIZE + IndexSize
}}.
-spec append(state(), ra_index(), ra_term(),
iodata() | {non_neg_integer(), iodata()}) ->
{ok, state()} | {error, full | inet:posix()}.
append(#state{cfg = #cfg{max_pending = PendingCount},
pending_count = PendingCount} = State0,
Index, Term, Data) ->
case flush(State0) of
{ok, State} ->
append(State, Index, Term, Data);
Err ->
Err
end;
append(#state{cfg = #cfg{version = Version,
mode = append} = Cfg,
index_offset = IndexOffset,
data_offset = DataOffset,
range = Range0,
pending_count = PendCnt,
pending_index = IdxPend0,
pending_data = DataPend0} = State,
Index, Term, {Length, Data}) ->
case is_full(State) of
false ->
% TODO: check length is less than #FFFFFFFF ??
Checksum = compute_checksum(Cfg, Data),
Fmt = idx_fmt(Version),
IndexData = encode_index_record(Fmt, Index, Term, DataOffset,
Length, Checksum),
Range = update_range(Range0, Index),
% fsync is done explicitly
{ok, State#state{index_offset = IndexOffset + Fmt#idx_fmt.record_size,
data_offset = DataOffset + Length,
range = Range,
pending_index = [IdxPend0, IndexData],
pending_data = [DataPend0, Data],
pending_count = PendCnt + 1}
};
true ->
{error, full}
end;
append(State, Index, Term, Data)
when is_list(Data) orelse
is_binary(Data) ->
%% convert into {Size, Data} tuple
append(State, Index, Term, {iolist_size(Data), Data}).
-spec sync(state()) -> {ok, state()} | {error, term()}.
sync(#state{cfg = #cfg{fd = Fd},
pending_index = []} = State) ->
case ra_file:sync(Fd) of
ok ->
{ok, State};
{error, _} = Err ->
Err
end;
sync(State0) ->
case flush(State0) of
{ok, State} ->
sync(State);
Err ->
Err
end.
-spec flush(state()) -> {ok, state()} | {error, term()}.
flush(#state{cfg = #cfg{fd = Fd},
pending_data = PendData,
pending_index = PendIndex,
index_offset = IdxOffs,
data_offset = DataOffs,
index_write_offset = IdxWriteOffs,
data_write_offset = DataWriteOffs} = State) ->
case file:pwrite(Fd, DataWriteOffs, PendData) of
ok ->
case file:pwrite(Fd, IdxWriteOffs, PendIndex) of
ok ->
{ok, State#state{pending_data = [],
pending_index = [],
pending_count = 0,
index_write_offset = IdxOffs,
data_write_offset = DataOffs}};
Err ->
Err
end;
Err ->
Err
end.
-spec fold(state(),
FromIdx :: ra_index(),
ToIdx :: ra_index(),
fun((binary()) -> term()),
fun(({ra_index(), ra_term(), term()}, Acc) -> Acc), Acc) ->
Acc when Acc :: term().
fold(#state{cfg = #cfg{mode = read}} = State,
FromIdx, ToIdx, Fun, AccFun, Acc) ->
fold0(State, FromIdx, ToIdx, Fun, AccFun, Acc, error).
-spec fold(state(),
FromIdx :: ra_index(),
ToIdx :: ra_index(),
fun((binary()) -> term()),
fun(({ra_index(), ra_term(), term()}, Acc) -> Acc), Acc,
MissingKeyStrat :: error | return) ->
Acc when Acc :: term().
fold(#state{cfg = #cfg{mode = read}} = State,
FromIdx, ToIdx, Fun, AccFun, Acc, MissingKeyStrat) ->
fold0(State, FromIdx, ToIdx, Fun, AccFun, Acc, MissingKeyStrat).
-spec is_modified(state()) -> boolean().
is_modified(#state{cfg = #cfg{fd = Fd},
data_offset = DataOffset} = State) ->
case is_full(State) of
true ->
%% a full segment cannot be appended to.
false;
false ->
%% get info and compare to data_offset
{ok, #file_info{size = Size}} =
prim_file:read_handle_info(Fd, [posix]),
Size > DataOffset
end.
-spec read_sparse(state(), [ra_index()],
fun((ra:index(), ra_term(), binary(), Acc) -> Acc),
Acc) ->
{ok, NumRead :: non_neg_integer(), Acc} | {error, modified}
when Acc :: term().
read_sparse(#state{} = State, Indexes, AccFun, Acc) ->
case is_modified(State) of
true ->
{error, modified};
false ->
read_sparse_no_checks(State, Indexes, AccFun, Acc)
end.
-spec read_sparse_no_checks(state(), [ra_index()],
fun((ra:index(), ra_term(), binary(), Acc) -> Acc),
Acc) ->
{ok, NumRead :: non_neg_integer(), Acc}
when Acc :: term().
read_sparse_no_checks(#state{index = Index,
num_entries = NumEntries,
cfg = #cfg{fd = Fd,
version = Version,
index_record_size = RecSize,
index_mode = IndexMode}},
Indexes, AccFun, Acc) ->
case IndexMode of
binary when is_binary(Index) ->
%% Binary mode: use binary search on the raw index binary
%% Index is guaranteed monotonically increasing
%% (overwrites fall back to map)
read_sparse_binary(Fd, Version, RecSize, NumEntries, Index,
Indexes, AccFun, Acc, 0);
_ ->
%% Map mode: use map lookups with caching
Cache0 = prepare_cache(Fd, Indexes, Index),
read_sparse0(Fd, Indexes, Index, Cache0, Acc, AccFun, 0)
end.
%% Binary mode: binary search on in-memory index binary
%% Uses a hint to optimize sequential ascending reads
%% Index is guaranteed monotonically increasing (overwrites fall back to map
%% mode)
read_sparse_binary(Fd, Version, RecSize, NumEntries, IndexBin,
Indexes, AccFun, Acc, Num) ->
%% Prepare cache for consecutive runs (same optimization as map mode)
Cache0 = prepare_cache_binary(Fd, Version, RecSize, NumEntries,
IndexBin, Indexes),
%% Start with no hint
read_sparse_binary(Fd, Version, RecSize, NumEntries, IndexBin,
Indexes, AccFun, Acc, Num, undefined, Cache0).
read_sparse_binary(_Fd, _Version, _RecSize, _NumEntries, _IndexBin,
[], _AccFun, Acc, Num, _Hint, _Cache) ->
{ok, Num, Acc};
read_sparse_binary(Fd, Version, RecSize, NumEntries, IndexBin,
[NextIdx | Rem] = Indexes, AccFun, Acc, Num, Hint, Cache0) ->
case binary_search_index_hinted(Version, IndexBin, RecSize, NumEntries,
NextIdx, Hint) of
{ok, {Term, Pos, Length, _Crc}, FoundPos} ->
case cache_read(Cache0, Pos, Length) of
false ->
%% Cache miss - try to prepare a new cache or read directly
case prepare_cache_binary(Fd, Version, RecSize, NumEntries,
IndexBin, Indexes) of
undefined ->
case file:pread(Fd, Pos, Length) of
{ok, Data} when byte_size(Data) == Length ->
NewHint = {NextIdx, FoundPos},
read_sparse_binary(Fd, Version, RecSize, NumEntries,
IndexBin, Rem, AccFun,
AccFun(NextIdx, Term, Data, Acc),
Num + 1, NewHint, undefined);
_ ->
exit({read_error, NextIdx})
end;
Cache ->
%% Retry with new cache
read_sparse_binary(Fd, Version, RecSize, NumEntries,
IndexBin, Indexes, AccFun, Acc,
Num, Hint, Cache)
end;
Data ->
%% Cache hit
NewHint = {NextIdx, FoundPos},
read_sparse_binary(Fd, Version, RecSize, NumEntries,
IndexBin, Rem, AccFun,
AccFun(NextIdx, Term, Data, Acc),
Num + 1, NewHint, Cache0)
end;
not_found ->
exit({missing_key, NextIdx})
end.
%% Prepare cache for binary mode by finding consecutive index runs
%% Uses binary search with position hints for efficiency
prepare_cache_binary(_Fd, _Version, _RecSize, _NumEntries, _IndexBin, [_]) ->
undefined;
prepare_cache_binary(Fd, Version, RecSize, NumEntries, IndexBin,
[FirstIdx | Rem]) ->
case consec_run(FirstIdx, FirstIdx, Rem) of
{Idx, Idx} ->
%% no run, no cache
undefined;
{FirstIdx, LastIdx} ->
%% Found a consecutive run, look up positions via binary search
%% Use hint from first lookup to speed up second lookup
case binary_search_index_with_pos(Version, IndexBin, RecSize,
0, NumEntries - 1, FirstIdx) of
{ok, {_, FstPos, FstLength, _}, FstFoundPos} ->
%% LastIdx > FirstIdx, so search forward from FstFoundPos
case binary_search_index_with_pos(Version, IndexBin, RecSize,
FstFoundPos, NumEntries - 1,
LastIdx) of
{ok, {_, LastPos, LastLength, _}, _} ->
make_cache(Fd, FstPos, FstLength, LastPos, LastLength);
not_found ->
undefined
end;
not_found ->
undefined
end
end.
%% Create cache by reading data range from file
make_cache(Fd, FstPos, FstLength, LastPos, LastLength) ->
CacheLen = cache_length(FstPos, FstLength, LastPos, LastLength),
{ok, CacheData} = file:pread(Fd, FstPos, CacheLen),
{FstPos, byte_size(CacheData), CacheData}.
%% Binary search with hint optimization for sequential ascending reads
%% Index is guaranteed monotonically increasing (overwrites fall back to map mode)
%% If NextIdx > LastIdx, search forward from LastPos (common consumer pattern)
%% Returns {ok, Record, Position} or not_found
binary_search_index_hinted(Version, Bin, RecSize, NumEntries, Idx,
undefined) ->
%% No hint, do full binary search
binary_search_index_with_pos(Version, Bin, RecSize, 0, NumEntries - 1, Idx);
binary_search_index_hinted(Version, Bin, RecSize, NumEntries, Idx,
{LastIdx, LastPos})
when Idx > LastIdx ->
%% Ascending access pattern: search forward from last position
%% First check if it's the very next entry (common case for sequential reads)
NextPos = LastPos + 1,
case NextPos < NumEntries of
true ->
case parse_index_entry(Version, Bin, NextPos * RecSize) of
{ok, {Idx, Term, DataOffset, Length, Crc}} ->
%% Found it at the next position - O(1)!
{ok, {Term, DataOffset, Length, Crc}, NextPos};
{ok, {FoundIdx, _, _, _, _}} when FoundIdx < Idx ->
%% Need to search forward from here (index is monotonic)
binary_search_index_with_pos(Version, Bin, RecSize,
NextPos + 1, NumEntries - 1,
Idx);
_ ->
%% Index at NextPos is > Idx, search between LastPos and NextPos
binary_search_index_with_pos(Version, Bin, RecSize,
LastPos, NextPos, Idx)
end;
false ->
not_found
end;
binary_search_index_hinted(Version, Bin, RecSize, _NumEntries, Idx,
{LastIdx, LastPos})
when Idx < LastIdx ->
%% Descending access: search from 0 to LastPos
binary_search_index_with_pos(Version, Bin, RecSize, 0, LastPos, Idx);
binary_search_index_hinted(Version, Bin, RecSize, _NumEntries, Idx,
{Idx, LastPos}) ->
%% Same index as last time - direct lookup
case parse_index_entry(Version, Bin, LastPos * RecSize) of
{ok, {Idx, Term, DataOffset, Length, Crc}} ->
{ok, {Term, DataOffset, Length, Crc}, LastPos};
_ ->
not_found
end.
%% Binary search that returns position along with result
binary_search_index_with_pos(_Version, _Bin, _RecSize, Low, High, _Idx)
when Low > High ->
not_found;
binary_search_index_with_pos(Version, Bin, RecSize, Low, High, Idx) ->
Mid = (Low + High) div 2,
Offset = Mid * RecSize,
case parse_index_entry(Version, Bin, Offset) of
{ok, {MidIdx, Term, DataOffset, Length, Crc}} ->
if MidIdx == Idx ->
{ok, {Term, DataOffset, Length, Crc}, Mid};
MidIdx < Idx ->
binary_search_index_with_pos(Version, Bin, RecSize,
Mid + 1, High, Idx);
true ->
binary_search_index_with_pos(Version, Bin, RecSize,
Low, Mid - 1, Idx)
end;
eof ->
%% Hit unwritten region, search lower half
binary_search_index_with_pos(Version, Bin, RecSize,
Low, Mid - 1, Idx)
end.
%% Map mode: use map lookups
read_sparse0(_Fd, [], _Index, _Cache, Acc, _AccFun, Num) ->
{ok, Num, Acc};
read_sparse0(Fd, [NextIdx | Rem] = Indexes, Index, Cache0, Acc, AccFun, Num)
when is_map_key(NextIdx, Index) ->
{Term, Pos, Length, _} = map_get(NextIdx, Index),
case cache_read(Cache0, Pos, Length) of
false ->
case prepare_cache(Fd, Indexes, Index) of
undefined ->
%% TODO: check for partial data?
{ok, Data} = file:pread(Fd, Pos, Length),
read_sparse0(Fd, Rem, Index, undefined,
AccFun(NextIdx, Term, Data, Acc),
AccFun, Num + 1);
Cache ->
read_sparse0(Fd, Indexes, Index, Cache,
Acc, AccFun, Num)
end;
Data ->
read_sparse0(Fd, Rem, Index, Cache0,
AccFun(NextIdx, Term, Data, Acc), AccFun, Num+1)
end;
read_sparse0(_Fd, [NextIdx | _], _Index, _Cache, _Acc, _AccFun, _Num) ->
exit({missing_key, NextIdx}).
cache_read({CPos, CLen, Bin}, Pos, Length)
when Pos >= CPos andalso
Pos + Length =< (CPos + CLen) ->
%% read fits inside cache
binary:part(Bin, Pos - CPos, Length);
cache_read(_, _, _) ->
false.
%% Prepare cache for map mode by finding consecutive index runs
prepare_cache(_Fd, [_], _SegIndex) ->
undefined;
prepare_cache(Fd, [FirstIdx | Rem], SegIndex) ->
prepare_cache_for_run(Fd, FirstIdx, Rem,
fun(Idx) -> map_get_(Idx, SegIndex) end).
%% Shared cache preparation logic for consecutive index runs
%% LookupFun returns {Term, Pos, Length, Crc} for an index
prepare_cache_for_run(Fd, FirstIdx, Rem, LookupFun) ->
case consec_run(FirstIdx, FirstIdx, Rem) of
{Idx, Idx} ->
%% no run, no cache
undefined;
{FirstIdx, LastIdx} ->
{_, FstPos, FstLength, _} = LookupFun(FirstIdx),
{_, LastPos, LastLength, _} = LookupFun(LastIdx),
make_cache(Fd, FstPos, FstLength, LastPos, LastLength)
end.
map_get_(Key, Map) when is_map_key(Key, Map) ->
map_get(Key, Map);
map_get_(Key, _Map) ->
exit({missing_key, Key}).
%% Unified index lookup that abstracts over map/binary modes
%% Returns {ok, index_record_data()} | not_found
lookup_index(#state{index = Index,
num_entries = NumEntries,
cfg = #cfg{version = Version,
index_record_size = RecSize,
index_mode = IndexMode}}, Idx) ->
case IndexMode of
binary when is_binary(Index) ->
binary_search_index(Version, Index, RecSize, 0, NumEntries - 1, Idx);
_ ->
case Index of
#{Idx := Record} ->
{ok, Record};
_ ->
not_found
end
end.
-spec term_query(state(), Idx :: ra_index()) -> option(ra_term()).
term_query(State, Idx) ->
case lookup_index(State, Idx) of
{ok, {Term, _, _, _}} ->
Term;
not_found ->
undefined
end.
fold0(_State, Idx, FinalIdx, _Fun, _AccFun, Acc, _)
when Idx > FinalIdx ->
Acc;
fold0(#state{cfg = Cfg, cache = Cache0} = State, Idx, FinalIdx, Fun, AccFun,
Acc0, MissingKeyStrat) ->
case lookup_index(State, Idx) of
{ok, {Term, Offset, Length, Crc} = IdxRec} ->
case pread(Cfg, Cache0, Offset, Length) of
{ok, Data, Cache} ->
case validate_checksum(Crc, Data) of
true ->
Acc = AccFun({Idx, Term, Fun(Data)}, Acc0),
fold0(State#state{cache = Cache}, Idx + 1, FinalIdx,
Fun, AccFun, Acc, MissingKeyStrat);
false ->
%% CRC check failures are irrecoverable
exit({ra_log_segment_crc_check_failure, Idx, IdxRec,
Cfg#cfg.filename})
end;
{error, partial_data} ->
%% we did not read the correct number of bytes
exit({ra_log_segment_unexpected_eof, Idx, IdxRec,
Cfg#cfg.filename})
end;
not_found when MissingKeyStrat == error ->
exit({missing_key, Idx, Cfg#cfg.filename});
not_found when MissingKeyStrat == return ->
Acc0
end.
-spec range(state()) -> option({ra_index(), ra_index()}).
range(#state{range = Range}) ->
Range.
-spec data_size(state()) -> non_neg_integer().
data_size(#state{data_offset = DataOffset, data_start = DataStart}) ->
DataOffset - DataStart.
-spec max_count(state()) -> non_neg_integer().
max_count(#state{cfg = #cfg{max_count = Max}}) ->
Max.
-spec filename(state()) -> file:filename().
filename(#state{cfg = #cfg{filename = Fn}}) ->
Fn.
-spec segref(state() | file:filename_all()) ->
option(ra_log:segment_ref()).
segref(#state{range = undefined}) ->
undefined;
segref(#state{range = Range,
cfg = #cfg{filename = Fn}}) ->
{ra_lib:to_binary(filename:basename(Fn)), Range};
segref(Filename) ->
{ok, Seg} = open(Filename, #{mode => read}),
SegRef = segref(Seg),
_ = close(Seg),
SegRef.
-type infos() :: #{size => non_neg_integer(),
index_size => non_neg_integer(),
max_count => non_neg_integer(),
file_type => regular | symlink,
ctime => integer(),
links => non_neg_integer(),
num_entries => non_neg_integer(),
ref => option(ra_log:segment_ref()),
indexes => ra_seq:state(),
live_size => non_neg_integer()
}.
-spec info(file:filename_all()) -> infos().
info(Filename) ->
info(Filename, undefined).
-spec info(file:filename_all(), option(ra_seq:state())) -> infos().
info(Filename, Live0)
when not is_tuple(Filename) ->
%% Optimized implementation that parses index data in a single pass
%% without building an intermediate map
{ok, #file_info{type = Type,
links = Links,
ctime = CTime}} = prim_file:read_link_info(Filename,
[raw, {time, posix}]),
{ok, Fd} = file:open(Filename, [read, raw, binary]),
try
{ok, Version, MaxCount} = read_header(Fd),
IndexRecordSize = index_record_size(Version),
IndexSize = MaxCount * IndexRecordSize,
DataStart = ?HEADER_SIZE + IndexSize,
%% Pass Live0 directly - ra_seq:in/2 is used for membership checks
%% This avoids expanding the sequence to a set which could be expensive
%% for large sequences. ra_seq:in/2 is efficient for compact sequences
%% with ranges.
case file:pread(Fd, ?HEADER_SIZE, IndexSize) of
{ok, Data} ->
{NumEntries, DataOffset, Range, IndexesSeq, LiveSize} =
parse_index_info(Version, Data, DataStart, Live0),
Ref = case Range of
undefined -> undefined;
_ -> {ra_lib:to_binary(filename:basename(Filename)), Range}
end,
#{size => DataOffset,
index_size => DataStart,
file_type => Type,
links => Links,
ctime => CTime,
max_count => MaxCount,
num_entries => NumEntries,
ref => Ref,
live_size => LiveSize,
indexes => IndexesSeq};
eof ->
%% Empty segment
#{size => DataStart,
index_size => DataStart,
file_type => Type,
links => Links,
ctime => CTime,
max_count => MaxCount,
num_entries => 0,
ref => undefined,
live_size => 0,
indexes => []}
end
after
_ = file:close(Fd)
end.
-spec is_same_as(state(), file:filename_all()) -> boolean().
is_same_as(#state{cfg = #cfg{filename = Fn0}}, Fn) ->
is_same_filename_all(Fn0, Fn).
-spec close(state()) -> ok | {error, term()}.
close(#state{cfg = #cfg{fd = Fd,
mode = append,
file_advise = FileAdvise}} = State0) ->
case sync(State0) of
{ok, State} ->
%% do not report errors from file:advise, it isn't necessary
case is_full(State) of
true ->
_ = file:advise(Fd, 0, 0, FileAdvise),
ok;
false ->
ok
end,
_ = file:close(Fd),
ok;
Err ->
_ = file:close(Fd),
Err
end;
close(#state{cfg = #cfg{fd = Fd}}) ->
%% ignore close errors when in read mode
_ = file:close(Fd),
ok.
-spec copy(state(), file:filename_all(), [ra:index()]) ->
{ok, state()} | {error, term()}.
copy(#state{} = State0, FromFile, Indexes)
when is_list(Indexes) ->
{ok, From} = open(FromFile, #{mode => read}),
SortedIndexes = lists:sort(Indexes),
State = copy_with_cache(From, State0, SortedIndexes),
_ = close(From),
sync(State).
%% Optimized copy that batches reads for consecutive index runs.
%% Uses the same caching strategy as read_sparse0 to minimize syscalls.
%% Preserves existing CRCs from the source segment to avoid recomputation.
copy_with_cache(#state{index = SrcIndex,
cfg = #cfg{fd = SrcFd}} = _From,
DstState, Indexes) ->
copy_with_cache_loop(SrcFd, SrcIndex, DstState, Indexes, undefined).
copy_with_cache_loop(_SrcFd, _SrcIndex, DstState, [], _Cache) ->
DstState;
copy_with_cache_loop(SrcFd, SrcIndex, DstState, [Idx | Rem] = Indexes, Cache0)
when is_map_key(Idx, SrcIndex) ->
{Term, Pos, Length, Crc} = map_get(Idx, SrcIndex),
case cache_read(Cache0, Pos, Length) of
false ->
%% Cache miss - prepare cache for this run of consecutive indexes
case prepare_cache(SrcFd, Indexes, SrcIndex) of
undefined ->
%% Single entry, no consecutive run - read directly
{ok, Data} = file:pread(SrcFd, Pos, Length),
{ok, DstState1} = append_raw(DstState, Idx, Term, Length,
Data, Crc),
copy_with_cache_loop(SrcFd, SrcIndex, DstState1, Rem,
undefined);
Cache ->
%% Retry with populated cache
copy_with_cache_loop(SrcFd, SrcIndex, DstState, Indexes,
Cache)
end;
Data ->
%% Cache hit - use cached data
{ok, DstState1} = append_raw(DstState, Idx, Term, Length,
Data, Crc),
copy_with_cache_loop(SrcFd, SrcIndex, DstState1, Rem, Cache0)
end;
copy_with_cache_loop(_SrcFd, _SrcIndex, _DstState, [Idx | _], _Cache) ->
exit({copy_missing_key, Idx}).
%% Append an entry with a pre-computed CRC, avoiding CRC recomputation.
%% This is used during copy operations where the source CRC is known valid.
-spec append_raw(state(), ra_index(), ra_term(),
non_neg_integer(), binary(), integer()) ->
{ok, state()} | {error, full | file:posix()}.
append_raw(#state{cfg = #cfg{max_pending = PendingCount},
pending_count = PendingCount} = State0,
Index, Term, Length, Data, Crc) ->
case flush(State0) of
{ok, State} ->
append_raw(State, Index, Term, Length, Data, Crc);
Err ->
Err
end;
append_raw(#state{cfg = #cfg{version = Version,
mode = append},
index_offset = IndexOffset,
data_offset = DataOffset,
range = Range0,
pending_count = PendCnt,
pending_index = IdxPend0,
pending_data = DataPend0} = State,
Index, Term, Length, Data, Crc) ->
case is_full(State) of
false ->
Fmt = idx_fmt(Version),
IndexData = encode_index_record(Fmt, Index, Term, DataOffset,
Length, Crc),
Range = update_range(Range0, Index),
{ok, State#state{index_offset = IndexOffset + Fmt#idx_fmt.record_size,
data_offset = DataOffset + Length,
range = Range,
pending_index = [IdxPend0, IndexData],
pending_data = [DataPend0, Data],
pending_count = PendCnt + 1}};
true ->
{error, full}
end.
%%% Internal
is_same_filename_all(Fn, Fn) ->
true;
is_same_filename_all(Fn0, Fn1) ->
B0 = filename:basename(Fn0),
B1 = filename:basename(Fn1),
ra_lib:to_binary(B0) == ra_lib:to_binary(B1).
update_range(undefined, Idx) ->
{Idx, Idx};
update_range({First, _Last}, Idx) ->
{min(First, Idx), Idx}.
recover_index(Fd, Version, MaxCount) ->
IndexSize = MaxCount * index_record_size(Version),
DataOffset = ?HEADER_SIZE + IndexSize,
case file:pread(Fd, ?HEADER_SIZE, IndexSize) of
{ok, Data} ->
parse_index_data(Version, Data, DataOffset);
eof ->
% if no entries have been written the file hasn't "stretched"
% to where the data offset starts.
{0, DataOffset, undefined, #{}}
end.
%% Binary mode: keep raw index binary, scan for range and count only
%% Returns {ok, NumEntries, DataOffset, Range, IndexBinary} if no overwrites,
%% or {fallback_to_map, NumEntries, DataOffset, Range, IndexMap} if overwrites detected.
recover_index_binary(Fd, Version, MaxCount) ->
IndexSize = MaxCount * index_record_size(Version),
DataOffset = ?HEADER_SIZE + IndexSize,
case file:pread(Fd, ?HEADER_SIZE, IndexSize) of
{ok, IndexBinary} ->
case scan_index_binary(Version, IndexBinary, DataOffset) of
{ok, NumEntries, ActualDataOffset, Range} ->
%% No overwrites - use binary mode
{ok, NumEntries, ActualDataOffset, Range, IndexBinary};
{overwrites_detected, _PartialNum, _PartialOffset, _PartialRange} ->
%% Overwrites detected - fall back to map mode for correctness
%% Re-parse the full index into a map
{NumEntries, ActualDataOffset, Range, IndexMap} =
parse_index_data(Version, IndexBinary, DataOffset),
{fallback_to_map, NumEntries, ActualDataOffset, Range,
IndexMap}
end;
eof ->
{ok, 0, DataOffset, undefined, <<>>}
end.
%% Scan index binary to find range and count entries.
%% Returns {ok, NumEntries, DataOffset, Range} if no overwrites detected,
%% or {overwrites_detected, NumEntries, DataOffset, Range} if overwrites found.
%% When overwrites are detected, caller should fall back to map mode.
scan_index_binary(Version, Bin, DataOffset) ->
Fmt = idx_fmt(Version),
scan_index_binary_loop(Fmt, Bin, 0, 0, 0, DataOffset, undefined).
scan_index_binary_loop(Fmt, Bin, ByteOffset, Num, LastIdx, DataOffset, Range) ->
case decode_index_record(Fmt, Bin, ByteOffset) of
eof ->
{ok, Num, DataOffset, Range};
{ok, {Idx, _Term, EntryOffset, Length, _Crc}} ->
%% Detect overwrite: if Idx goes backwards, fall back to map mode
case Idx < LastIdx of
true ->
{overwrites_detected, Num + 1, EntryOffset + Length,
update_range(Range, Idx)};
false ->
NewRange = update_range(Range, Idx),
NewDataOffset = EntryOffset + Length,
RecSize = Fmt#idx_fmt.record_size,
scan_index_binary_loop(Fmt, Bin, ByteOffset + RecSize,
Num + 1, Idx, NewDataOffset, NewRange)
end
end.
%% Binary search on in-memory index binary
%% Returns {ok, {Term, Offset, Length, Crc}} or not_found
binary_search_index(_Version, _Bin, _RecSize, Low, High, _Idx)
when Low > High ->
not_found;
binary_search_index(Version, Bin, RecSize, Low, High, Idx) ->
Mid = (Low + High) div 2,
Offset = Mid * RecSize,
case parse_index_entry(Version, Bin, Offset) of
{ok, {MidIdx, Term, DataOffset, Length, Crc}} ->
if MidIdx == Idx ->
{ok, {Term, DataOffset, Length, Crc}};
MidIdx < Idx ->
binary_search_index(Version, Bin, RecSize, Mid + 1,
High, Idx);
true ->
binary_search_index(Version, Bin, RecSize, Low, Mid - 1, Idx)
end;
eof ->
%% Hit unwritten region, search lower half
binary_search_index(Version, Bin, RecSize, Low, Mid - 1, Idx)
end.
%% Parse an index entry from a binary at the given offset
%% Wrapper that uses the unified decode_index_record
parse_index_entry(Version, Bin, Offset) ->
decode_index_record(idx_fmt(Version), Bin, Offset).
dump_index(File) ->
{ok, Fd} = file:open(File, [read, raw, binary
]),
{ok, Version, MaxCount} = read_header(Fd),
IndexSize = MaxCount * index_record_size(Version),
case file:pread(Fd, ?HEADER_SIZE, IndexSize) of
{ok, Data} ->
D = [{I, T, O} || {I, T, O, _N} <- dump_index_data(Data, [])],
_ = file:close(Fd),
D;
eof ->
_ = file:close(Fd),
% if no entries have been written the file hasn't "stretched"
% to where the data offset starts.
DataOffset = ?HEADER_SIZE + IndexSize,
{0, DataOffset, undefined, #{}}
end.
dump(File) ->
dump(File, fun (B) -> B end).
dump(File, Fun) ->
{ok, S0} = open(File, #{mode => read}),
{Idx, Last} = range(S0),
L = fold(S0, Idx, Last, Fun,
fun (E, A) -> [E | A] end, []),
_ = close(S0),
lists:reverse(L).
dump_index_data(<<Idx:64/unsigned, Term:64/unsigned,
Offset:64/unsigned, Length:32/unsigned,
_:32/unsigned, Rest/binary>>,
Acc) ->
dump_index_data(Rest, [{Idx, Term, Offset, Length} | Acc]);
dump_index_data(_, Acc) ->
lists:reverse(Acc).
parse_index_data(Version, Data, DataOffset) ->
Fmt = idx_fmt(Version),
parse_index_data_loop(Fmt, Data, 0, 0, 0, DataOffset, undefined, #{}).
parse_index_data_loop(Fmt, Bin, ByteOffset, Num, LastIdx, DataOffset, Range, Index) ->
case decode_index_record(Fmt, Bin, ByteOffset) of
eof ->
% end of data or partially written index
{Num, DataOffset, Range, Index};
{ok, {Idx, Term, Offset, Length, Crc}} ->
% trim index entries if Idx goes "backwards"
Index1 = case Idx < LastIdx of
true -> maps:filter(fun (K, _) when K > Idx -> false;
(_, _) -> true
end, Index);
false -> Index
end,
RecSize = Fmt#idx_fmt.record_size,
parse_index_data_loop(Fmt, Bin, ByteOffset + RecSize, Num + 1, Idx,
Offset + Length,
update_range(Range, Idx),
Index1#{Idx => {Term, Offset, Length, Crc}})
end.
%% Optimized index parsing for info/2 that computes stats in a single pass
%% without building a full index map. Returns:
%% {NumEntries, DataOffset, Range, IndexesSeq, LiveSize}
parse_index_info(Version, Data, DataOffset, LiveSeq) ->
Fmt = idx_fmt(Version),
parse_index_info_loop(Fmt, Data, 0, 0, 0, DataOffset, undefined, [], 0, LiveSeq).
parse_index_info_loop(Fmt, Bin, ByteOffset, Num, LastIdx, DataOffset, Range,
IdxAcc, LiveSize, LiveSeq) ->
case decode_index_record(Fmt, Bin, ByteOffset) of
eof ->
%% End of data or partially written index
{Num, DataOffset, Range, ra_seq:from_list(lists:reverse(IdxAcc)),
LiveSize};
{ok, {Idx, _Term, Offset, Length, _Crc}} ->
%% Handle index going backwards (trim entries)
IdxAcc1 = case Idx < LastIdx of
true ->
lists:dropwhile(fun(I) -> I > Idx end, IdxAcc);
false ->
IdxAcc
end,
%% Compute live size: if LiveSeq is undefined, all entries are live
LiveSize1 = case LiveSeq of
undefined ->
LiveSize + Length;
_ ->
case ra_seq:in(Idx, LiveSeq) of
true ->
LiveSize + Length;
false ->
LiveSize
end
end,
RecSize = Fmt#idx_fmt.record_size,
parse_index_info_loop(Fmt, Bin, ByteOffset + RecSize, Num + 1, Idx,
Offset + Length,
update_range(Range, Idx),
[Idx | IdxAcc1], LiveSize1, LiveSeq)
end.
write_header(MaxCount, Fd) ->
Header = <<?MAGIC, ?VERSION:16/unsigned, MaxCount:16/unsigned>>,
{ok, 0} = file:position(Fd, 0),
ok = file:write(Fd, Header),
ok = ra_file:sync(Fd).
read_header(Fd) ->
case file:pread(Fd, 0, ?HEADER_SIZE) of
{ok, Buffer} ->
case Buffer of
<<?MAGIC, Version:16/unsigned, MaxCount:16/unsigned>>
when Version =< ?VERSION ->
{ok, Version, MaxCount};
_ ->
{error, invalid_segment_format}
end;
eof ->
{error, missing_segment_header};
{error, _} = Err ->
Err
end.
pread(#cfg{access_pattern = random,
fd = Fd}, Cache, Pos, Length) ->
%% no cache
{ok, Data} = file:pread(Fd, Pos, Length),
case byte_size(Data) of
Length ->
{ok, Data, Cache};
_ ->
{error, partial_data}
end;
pread(#cfg{}, {CPos, CLen, Bin} = Cache, Pos, Length)
when Pos >= CPos andalso
Pos + Length =< (CPos + CLen) ->
%% read fits inside cache
{ok, binary:part(Bin, Pos - CPos, Length), Cache};
pread(#cfg{access_pattern = sequential,
fd = Fd} = Cfg, undefined, Pos, Length) ->
CacheLen = max(Length, ?READ_AHEAD_B),
{ok, CacheData} = file:pread(Fd, Pos, CacheLen),
case byte_size(CacheData) >= Length of
true ->
pread(Cfg, {Pos, byte_size(CacheData), CacheData}, Pos, Length);
false ->
{error, partial_data}
end;
pread(Cfg, {_, _, _}, Pos, Length) ->
%% invalidate cache
pread(Cfg, undefined, Pos, Length).
index_record_size(2) ->
?INDEX_RECORD_SIZE_V2;
index_record_size(1) ->
?INDEX_RECORD_SIZE_V1.
%% Index format abstraction - creates format descriptor for a version
idx_fmt(2) ->
#idx_fmt{version = 2,
record_size = ?INDEX_RECORD_SIZE_V2,
offset_size = 64};
idx_fmt(1) ->
#idx_fmt{version = 1,
record_size = ?INDEX_RECORD_SIZE_V1,
offset_size = 32}.
%% Decode an index record from binary at given byte offset
%% Returns {ok, {Idx, Term, DataOffset, Length, Crc}} | eof
decode_index_record(#idx_fmt{version = 2}, Bin, Offset)
when byte_size(Bin) >= Offset + ?INDEX_RECORD_SIZE_V2 ->
case Bin of
<<_:Offset/binary, 0:64/unsigned, 0:64/unsigned, 0:64/unsigned,
0:32/unsigned, 0:32/integer, _/binary>> ->
eof;
<<_:Offset/binary, Idx:64/unsigned, Term:64/unsigned,
DataOffset:64/unsigned, Length:32/unsigned,
Crc:32/integer, _/binary>> ->
{ok, {Idx, Term, DataOffset, Length, Crc}}
end;
decode_index_record(#idx_fmt{version = 1}, Bin, Offset)
when byte_size(Bin) >= Offset + ?INDEX_RECORD_SIZE_V1 ->
case Bin of
<<_:Offset/binary, 0:64/unsigned, 0:64/unsigned, 0:32/unsigned,
0:32/unsigned, 0:32/integer, _/binary>> ->
eof;
<<_:Offset/binary, Idx:64/unsigned, Term:64/unsigned,
DataOffset:32/unsigned, Length:32/unsigned,
Crc:32/integer, _/binary>> ->
{ok, {Idx, Term, DataOffset, Length, Crc}}
end;
decode_index_record(_, _, _) ->
eof.
%% Encode an index record to binary
encode_index_record(#idx_fmt{version = 2}, Idx, Term, DataOffset, Length, Crc) ->
<<Idx:64/unsigned, Term:64/unsigned,
DataOffset:64/unsigned, Length:32/unsigned,
Crc:32/unsigned>>;
encode_index_record(#idx_fmt{version = 1}, Idx, Term, DataOffset, Length, Crc) ->
<<Idx:64/unsigned, Term:64/unsigned,
DataOffset:32/unsigned, Length:32/unsigned,
Crc:32/unsigned>>.
%% returns the first and last indexes of the next consecutive run
%% of indexes
consec_run(First, Last, []) ->
{First, Last};
consec_run(First, Last, [Next | Rem])
when Next == Last + 1 ->
consec_run(First, Next, Rem);
consec_run(First, Last, _) ->
{First, Last}.
cache_length(FstPos, FstLength, LastPos, LastLength) ->
%% The cache needs to be at least as large as the next entry
%% but no larger than ?READ_AHEAD_B
MaxCacheLen = LastPos + LastLength - FstPos,
%% read at least the remainder of the block from
%% the first position or the length of the first record
MinCacheLen = max(FstLength, ?BLOCK_SIZE - (FstPos rem ?BLOCK_SIZE)),
max(MinCacheLen, min(MaxCacheLen, ?READ_AHEAD_B)).
compute_checksum(#cfg{compute_checksums = false}, _) ->
0;
compute_checksum(#cfg{}, Data) ->
erlang:crc32(Data).
validate_checksum(0, _) ->
true;
validate_checksum(Crc, Data) ->
Crc == erlang:crc32(Data).
is_full(#state{cfg = #cfg{max_size = MaxSize},
index_offset = IndexOffset,
data_start = DataStart,
data_offset = DataOffset}) ->
IndexOffset >= DataStart orelse
(DataOffset - DataStart) > MaxSize.
-ifdef(TEST).
-include_lib("eunit/include/eunit.hrl").
cache_length_test() ->
B = ?BLOCK_SIZE,
B2 = ?BLOCK_SIZE * 2,
%% total request is smaller than block size
?assertEqual(B, cache_length(B, 10, B + 10, 10)),
%% larger than block size
?assertEqual(B2, cache_length(B, B, B + B, B)),
%% large first entry
?assertEqual(?READ_AHEAD_B * 2, cache_length(B, ?READ_AHEAD_B * 2,
?READ_AHEAD_B * 4, B)),
%% if the request is oversized, return the max read ahead as cache size
?assertEqual(?READ_AHEAD_B, cache_length(B, B, ?READ_AHEAD_B * 2, B)),
ok.
-endif.