Skip to content

Commit f1af8ac

Browse files
committed
Fix find_fragment timestamp predicate and add property-based tests
`find_fragment` used `Ts >= FTs` to select the manifest fragment for a timestamp subscription. This predicate finds the last fragment whose first timestamp is at or before `Ts`. When `Ts` falls between two fragments — after fragment N's `LTs` but before fragment N+1's `FTs` — the predicate incorrectly returns fragment N instead of N+1, causing the reader to start from the wrong position. The correct predicate is `Ts >= LTs`: find the first fragment whose last timestamp is greater than or equal to `Ts`. This correctly identifies the fragment whose range contains `Ts`, or the earliest fragment after `Ts` if `Ts` falls in a gap between fragments. The index position calculation for the timestamp case uses `min(Idx0, NumEntries - 1)` rather than `saturating_decr` because the partition point already identifies the target fragment directly. The `read_from_remote_tier_by_timestamp` integration test is also fixed: `Timestamp3` must be captured before publishing offset 2 so that `find_index_position` returns offset 2 rather than offset 3. - Fix `init_offset_reader/2` calling `osiris_log` directly for the local path; route through `init_local_reader/2` instead - Fix `range_spec_to_location_number/2`: suffix range used addition instead of subtraction; open-ended byte range returned `infinity` instead of the remaining file size - Preserve `?C_OSIRIS_LOG_FIRST_OFFSET` after local segment deletion; only update the shared atomic used for local/remote tier routing - Upload CT logs as an artifact on test failure - Reduce `?MAX_SEGMENT_SIZE_BYTES` to 1 MiB in test builds to trigger segment rolls without large payloads - Add integration tests for reading stream data from the remote tier by offset and timestamp - Convert `unit_SUITE` to use PropEr; add property-based tests for `range_spec_to_location_number/2` - Add property-based tests for `find_fragment` timestamp behaviour, covering timestamps within fragment ranges, in gaps between fragments, and after all fragments - Add property-based tests for `rabbitmq_stream_s3_array`: `partition_point`, `binary_search_by`, `rfind`, and `fold` - Add property-based tests for `find_index_position` offset and timestamp specs - Add diagnostic logging to `read_from_remote_tier_by_timestamp` to surface manifest entry timestamps and captured timestamps in CI output - Add `get_range_by_reference/1` and `get_manifest_by_reference/1` test helpers to `rabbitmq_stream_s3_server` - Support container credentials in the integration test group
1 parent 326af66 commit f1af8ac

10 files changed

Lines changed: 808 additions & 44 deletions

.github/workflows/build-test.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,11 @@ jobs:
4949
run: make -C ${{ github.workspace }}/rabbitmq-server/deps/rabbitmq_stream_s3
5050
- name: tests
5151
run: make -C ${{ github.workspace }}/rabbitmq-server/deps/rabbitmq_stream_s3 tests
52+
- name: maybe upload CT logs
53+
if: failure()
54+
uses: actions/upload-artifact@v4
55+
with:
56+
name: ct-logs-${{ matrix.rmq-version }}-otp-${{ matrix.otp-version }}
57+
path: ${{ github.workspace }}/rabbitmq-server/logs/
5258
- name: dialyze
5359
run: make -C ${{ github.workspace }}/rabbitmq-server/deps/rabbitmq_stream_s3 dialyze

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ PROJECT_DESCRIPTION = RabbitMQ S3 plugin
33
PROJECT_MOD = rabbitmq_stream_s3_app
44

55
DEPS = rabbit_common rabbit osiris khepri gun
6-
TEST_DEPS = rabbitmq_ct_helpers rabbitmq_ct_client_helpers
6+
TEST_DEPS = rabbitmq_ct_helpers rabbitmq_ct_client_helpers rabbitmq_stream proper
77
LOCAL_DEPS = xmerl
88

99
DEP_EARLY_PLUGINS = rabbit_common/mk/rabbitmq-early-plugin.mk

docs/TODO.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# TODO
2+
3+
## `stream_test_utils` bug: `receive_stream_commands/3` discards accumulated data on retry
4+
5+
`stream_test_utils:receive_stream_commands/3` loops up to 10 times calling
6+
`gen_tcp:recv/3`, accumulating incoming data into the `rabbit_stream_core`
7+
state. When the loop is exhausted without a complete frame, it returns the
8+
atom `empty` — but discards the updated core state containing the partially
9+
accumulated frame data.
10+
11+
Any caller that retries by calling `receive_stream_commands` again with the
12+
original core state will re-read from an empty buffer, losing the bytes
13+
already received. For large chunks (e.g. a 2 MB message), this means the
14+
frame can never be assembled.
15+
16+
The fix is to either return the updated core state alongside `empty`, or for
17+
callers to implement their own recv loop that preserves the accumulated state
18+
across iterations. `s3_streams_SUITE` works around this with a local
19+
`receive_deliver/3` helper that calls `rabbit_stream_core:next_command/1` and
20+
`rabbit_stream_core:incoming_data/2` directly.

include/rabbitmq_stream_s3.hrl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,12 @@
258258
-define(MAX_FRAGMENT_SIZE_B, 67_108_864).
259259
%% %% 1 GiB (2^30 B)
260260
%% -define(MAX_SEGMENT_SIZE_BYTES, 1_073_741_824).
261-
%% 1/G GiB (2^29 B)
261+
%% 1/2 GiB (2^29 B), reduced to 1 MiB in tests to trigger segment rolls.
262+
-ifdef(TEST).
263+
-define(MAX_SEGMENT_SIZE_BYTES, 1_048_576).
264+
-else.
262265
-define(MAX_SEGMENT_SIZE_BYTES, 536_870_912).
266+
-endif.
263267

264268
-type byte_offset() :: non_neg_integer().
265269
-type checksum() :: non_neg_integer().

src/rabbitmq_stream_s3_api_fs.erl

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ associated file in that folder.
3030
set_data_dir/1
3131
]).
3232

33+
-ifdef(TEST).
34+
-export([range_spec_to_location_number/2]).
35+
-endif.
36+
3337
-behaviour(rabbitmq_stream_s3_api).
3438

3539
-type connection() :: rabbitmq_stream_s3_api:connection().
@@ -238,16 +242,16 @@ with_timeout(Timeout, Fun) ->
238242
range_spec_to_location_number(FileSize, SuffixRange) when
239243
is_integer(SuffixRange), SuffixRange < 0
240244
->
241-
Location = FileSize - SuffixRange,
245+
Location = FileSize + SuffixRange,
242246
{Location, -SuffixRange};
243247
range_spec_to_location_number(FileSize, SuffixRange) when is_integer(SuffixRange) ->
244248
Location = 0,
245249
Number = min(SuffixRange, FileSize),
246250
{Location, Number};
247-
range_spec_to_location_number(_FileSize, {StartByte, EndByte}) ->
251+
range_spec_to_location_number(FileSize, {StartByte, EndByte}) ->
248252
Number =
249253
case EndByte of
250-
undefined -> infinity;
254+
undefined -> FileSize - StartByte;
251255
_ -> EndByte - StartByte + 1
252256
end,
253257
{StartByte, Number}.

src/rabbitmq_stream_s3_log_reader.erl

Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ tier.
8282
code_change/3
8383
]).
8484

85+
-ifdef(TEST).
86+
-export([find_fragment/3, find_index_position/2]).
87+
-endif.
88+
8589
%%%===================================================================
8690
%%% osiris_log_reader callbacks
8791
%%%===================================================================
@@ -101,7 +105,7 @@ init_offset_reader(OffsetSpec, Config) ->
101105
{ok, Fragment, Position, ChunkId} ->
102106
init_remote_reader(Fragment, Position, ChunkId, Config);
103107
{local, LocalSpec} ->
104-
osiris_log:init_offset_reader(LocalSpec, Config);
108+
init_local_reader(LocalSpec, Config);
105109
{error, _} = Err ->
106110
Err
107111
end.
@@ -674,14 +678,19 @@ find_fragment(Entries, Spec, GetGroup) ->
674678
{offset, Offset} ->
675679
fun(?ENTRY(O, _FTs, _LTs, _K, _)) -> Offset >= O end;
676680
{timestamp, Ts} ->
677-
fun(?ENTRY(_O, FTs, _LTs, _K, _)) -> Ts >= FTs end
681+
fun(?ENTRY(_O, _FTs, LTs, _K, _)) -> Ts >= LTs end
678682
end,
679683
Idx0 = rabbitmq_stream_s3_array:partition_point(
680684
PartitionPredicate,
681685
?ENTRY_B,
682686
Entries
683687
),
684-
Idx = saturating_decr(Idx0),
688+
NumEntries = byte_size(Entries) div ?ENTRY_B,
689+
Idx =
690+
case Spec of
691+
{offset, _} -> saturating_decr(Idx0);
692+
{timestamp, _} -> min(Idx0, NumEntries - 1)
693+
end,
685694
case rabbitmq_stream_s3_array:at(Idx, ?ENTRY_B, Entries) of
686695
?FRAGMENT(EntryOffset, _FTs, _LTs, _Sq, _Sz, _) ->
687696
EntryOffset;
@@ -766,39 +775,14 @@ find_fragment_test() ->
766775
Size = 200,
767776
FragmentEntries = <<
768777
?FRAGMENT(
769-
%% Fragments every 20 offsets, 0..=2000
770778
(N * 20),
771-
%% Timestamps between 2000ms ago and `Ts`
772779
(Ts - 2000 + N * 20),
773780
(Ts - 2000 + (N + 1) * 20),
774781
0,
775782
Size
776783
)
777784
|| N <- lists:seq(0, 100)
778785
>>,
779-
780-
FindFragment1 = fun(Spec) ->
781-
find_fragment(
782-
FragmentEntries,
783-
Spec,
784-
%% There are only fragments in this manifest.
785-
fun(_, _, _) -> erlang:error(unimplemented) end
786-
)
787-
end,
788-
%% Offsets:
789-
?assertEqual(0, FindFragment1({offset, 0})),
790-
?assertEqual(40, FindFragment1({offset, 50})),
791-
?assertEqual(100, FindFragment1({offset, 100})),
792-
?assertEqual(2_000, FindFragment1({offset, 10_000})),
793-
%% Timestamps:
794-
?assertEqual(0, FindFragment1({timestamp, Ts - 2000})),
795-
%% When placed between two chunks timestamp search prefers the later chunk.
796-
%% But when searching in fragments we want to prefer the earlier fragment
797-
%% since it most likely contains the target timestamp.
798-
?assertEqual(40, FindFragment1({timestamp, Ts - 2000 + 50})),
799-
?assertEqual(100, FindFragment1({timestamp, Ts - 2000 + 100})),
800-
?assertEqual(2_000, FindFragment1({timestamp, Ts - 2000 + 10_000})),
801-
802786
%% Factor out those fragments into a group.
803787
NextFragmentEntries = <<
804788
?FRAGMENT((N * 20), (Ts - 2000 + N * 20), (Ts - 2000 + (N + 1) * 20), 0, Size)
@@ -829,7 +813,8 @@ find_fragment_test() ->
829813
?assertEqual(0, FindFragment2({offset, 0})),
830814
?assertEqual(40, FindFragment2({offset, 50})),
831815
?assertEqual(40, FindFragment2({timestamp, Ts - 2000 + 50})),
832-
816+
%% Offset and timestamp assertions on flat fragment lists are omitted here;
817+
%% they are covered by the property-based tests in unit_SUITE.
833818
ok.
834819

835820
find_index_position_test() ->

src/rabbitmq_stream_s3_server.erl

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@
6464
delete_stream/1
6565
]).
6666

67+
-ifdef(TEST).
68+
-export([get_range_by_reference/1, get_manifest_by_reference/1]).
69+
-endif.
70+
6771
%% Useful for other modules
6872
-export([
6973
get_fragment_trailer/1,
@@ -224,6 +228,29 @@ handle_call(#get_manifest{stream = StreamId}, From, State) ->
224228
%% readers to find anything within branches.
225229
Event = #manifest_requested{stream = StreamId, requester = From},
226230
{noreply, evolve_event(Event, State)};
231+
handle_call(
232+
{get_range_by_reference, Reference},
233+
_From,
234+
#?MODULE{references = References} = State
235+
) ->
236+
Range =
237+
case References of
238+
#{Reference := StreamId} -> get_range(StreamId);
239+
_ -> empty
240+
end,
241+
{reply, Range, State};
242+
handle_call(
243+
{get_manifest_by_reference, Reference},
244+
From,
245+
#?MODULE{references = References} = State
246+
) ->
247+
case References of
248+
#{Reference := StreamId} ->
249+
Event = #manifest_requested{stream = StreamId, requester = From},
250+
{noreply, evolve_event(Event, State)};
251+
_ ->
252+
{reply, undefined, State}
253+
end;
227254
handle_call(Request, From, State) ->
228255
?LOG_INFO(?MODULE_STRING " received unexpected call from ~p: ~W", [From, Request, 10]),
229256
{noreply, State}.
@@ -452,9 +479,20 @@ apply_effect(
452479
is_integer(FstTs)
453480
->
454481
counters:add(counter(), ?C_LOCAL_TIER_RETENTION_EVALUATIONS, 1),
482+
%% Update the shared atomic so that the log reader routes offsets
483+
%% below FstOff to the remote tier. Do NOT update
484+
%% ?C_OSIRIS_LOG_FIRST_OFFSET here: that counter reflects the
485+
%% first offset of the entire stream (local + remote) and is set
486+
%% by #set_range{} to the remote tier's first offset. Overwriting
487+
%% it with the local tier's first offset would cause stream metrics
488+
%% to hide the existence of data in the remote tier.
455489
osiris_log_shared:set_first_chunk_id(Shared, FstOff),
456490
counters:put(Cnt, ?C_OSIRIS_LOG_SEGMENTS, NumSegLeft);
457-
(_) ->
491+
(Result) ->
492+
?LOG_DEBUG(
493+
"trigger_retention EvalFun: unexpected result ~p for stream '~ts', skipping set_first_chunk_id",
494+
[Result, StreamId]
495+
),
458496
ok
459497
end,
460498
Spec = [{'fun', local_retention_fun(StreamId)}],
@@ -1117,6 +1155,14 @@ counter() ->
11171155
-ifdef(TEST).
11181156
-include_lib("eunit/include/eunit.hrl").
11191157

1158+
-spec get_range_by_reference(stream_reference()) -> rabbitmq_stream_s3:range().
1159+
get_range_by_reference(Reference) ->
1160+
gen_server:call(?SERVER, {get_range_by_reference, Reference}, infinity).
1161+
1162+
-spec get_manifest_by_reference(stream_reference()) -> #manifest{} | undefined.
1163+
get_manifest_by_reference(Reference) ->
1164+
gen_server:call(?SERVER, {get_manifest_by_reference, Reference}, infinity).
1165+
11201166
eval_local_retention_test() ->
11211167
IdxFiles = [
11221168
<<"/data/00000000000000000000.index">>,

test/rabbitmq_stream_s3_api_aws_SUITE.erl

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ end_per_suite(Config) ->
7878
7979
init_per_group(integration, Config) ->
8080
Cfg = {
81+
os:getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI"),
8182
os:getenv("AWS_ACCESS_KEY_ID"),
8283
os:getenv("AWS_SECRET_ACCESS_KEY"),
8384
os:getenv("AWS_SESSION_TOKEN"),
@@ -88,18 +89,24 @@ init_per_group(integration, Config) ->
8889
{skip,
8990
"AWS access credentials are not set. Skipping this group is OK! See the moduledoc for more information."},
9091
case Cfg of
91-
{false, _, _, _, _} ->
92+
{AwsCredentialsUri, _, _, _, _, Bucket0} when
93+
AwsCredentialsUri =/= false andalso Bucket0 =/= false
94+
->
95+
{ok, _} = application:ensure_all_started(gun),
96+
ok = application:set_env(rabbitmq_stream_s3, bucket, list_to_binary(Bucket0)),
97+
Config;
98+
{_, false, _, _, _, _} ->
9299
Skip;
93-
{_, false, _, _, _} ->
100+
{_, _, false, _, _, _} ->
94101
Skip;
95-
{_, _, false, _, _} ->
102+
{_, _, _, false, _, _} ->
96103
Skip;
97-
{_, _, _, false, _} ->
104+
{_, _, _, _, false, _} ->
98105
Skip;
99-
{_, _, _, _, false} ->
106+
{_, _, _, _, _, false} ->
100107
Skip;
101-
{AccessKey, SecretKey, SecurityToken, Region, Bucket} ->
102-
application:ensure_all_started(gun),
108+
{_, AccessKey, SecretKey, SecurityToken, Region, Bucket1} ->
109+
{ok, _} = application:ensure_all_started(gun),
103110
ok = application:set_env(rabbitmq_stream_s3, aws_access_key, list_to_binary(AccessKey)),
104111
ok = application:set_env(rabbitmq_stream_s3, aws_secret_key, list_to_binary(SecretKey)),
105112
ok = application:set_env(
@@ -108,7 +115,7 @@ init_per_group(integration, Config) ->
108115
list_to_binary(SecurityToken)
109116
),
110117
ok = application:set_env(rabbitmq_stream_s3, aws_region, list_to_binary(Region)),
111-
ok = application:set_env(rabbitmq_stream_s3, bucket, list_to_binary(Bucket)),
118+
ok = application:set_env(rabbitmq_stream_s3, bucket, list_to_binary(Bucket1)),
112119
Config
113120
end.
114121

0 commit comments

Comments
 (0)