Skip to content

Instantly share code, notes, and snippets.

@lukebakken
Last active May 20, 2026 17:21
Show Gist options
  • Select an option

  • Save lukebakken/4556a0f2e194c467416040b46eed138b to your computer and use it in GitHub Desktop.

Select an option

Save lukebakken/4556a0f2e194c467416040b46eed138b to your computer and use it in GitHub Desktop.

Overview

This is a substantial architectural rewrite (~20k lines changed). The upload path is redesigned around a per-stream remote replica reader that behaves like an osiris replica but uploads to S3 instead of writing to disk. The old rabbitmq_stream_s3_server (1288 lines) and rabbitmq_stream_s3_machine (1848 lines) - a functional core / imperative shell pair that ran upload lifecycle inside the writer's hot path - are deleted and replaced by:

  • rabbitmq_stream_s3_replica_reader - per-stream gen_server (imperative shell)
  • rabbitmq_stream_s3_replica_reader_core - pure functional core
  • rabbitmq_stream_s3_governor - per-node token-bucket upload pacing
  • rabbitmq_stream_s3_hooks - replaces osiris_log_manifest behaviour
  • rabbitmq_stream_s3_manifest_replica - per-node manifest cache and broadcast receiver
  • rabbitmq_stream_s3_fragment_assembly - pure chunk-to-fragment accumulator
  • rabbitmq_stream_s3_fragment_iterator - tree traversal over manifest entries
  • rabbitmq_stream_s3_token_bucket - pure rate limiter

The stated goals (bounded time-to-S3, zero hot-path overhead, single upload owner) are well-served by the new design.


Architecture and Design

Strengths:

  1. Functional core / imperative shell split is well-executed. The _core module has no I/O and is deterministically testable. The replica_reader_SUITE + replica_reader_core_SUITE together give very solid coverage. The split is cleaner here than the old machine/server split because the core is truly pure - no ETS, no erlang:system_time except in init/2 and tick/2 where it is unavoidable.

  2. Out-of-order completion handling is correct. The in_flight queue enforces cut order. pending_completions buffers arrivals. drain_completions/1 walks the front in order. This is a clean resolution to the problem that caused complexity in the old server.

  3. appended_since_persist tracking is subtle but correct. The PR description notes that fragments arriving during an in-flight persist must be held for the next broadcast. The binary slicing at persist_complete (<<_:PersistedBytes/binary, Remaining/binary>>) is correct given in_persist_count was snapshotted at start_persist time. (A bug in this logic was found and fixed post-merge in 1e62661.)

  4. Epoch fencing via Khepri optimistic concurrency. The #if_payload_version{} condition in db.erl fences deposed writers correctly, and the reinitialize effect on conflict is clean.

  5. Token bucket implementation is correct and the governor properly handles the unlimited fast path without branching inside every submission.


Issues

1. Linked group-upload task can permanently deadlock the replica reader (replica_reader.erl:659)

execute_effect(
    {upload_group, StreamId, Kind, Entries, _Pos, _Len},
    State0
) ->
    Self = self(),
    spawn_link(fun() ->
        ...
        Self ! {group_upload_result, Result}
    end),
    State0#state{pending_group_kind = Kind};

Three facts combine to create a permanent hang:

  1. The replica reader traps exits (process_flag(trap_exit, true) at line 251), so linked process deaths arrive as {'EXIT', Pid, Reason} messages.
  2. The catch-all handle_info(_Info, State) at line 416 silently discards those messages.
  3. The core state's rebalance_in_flight flag is only cleared by receiving {group_upload_result, ...} — either via group_upload_complete (core line 306) or group_upload_failed (core line 349). No other code path resets it.

The do_upload_group call is already wrapped in a try/catch inside the spawned fun, covering Erlang-level exceptions. But if the process is killed externally (OOM killer, exit(Pid, kill) from a supervisor or memory alarm handler), no {group_upload_result, ...} message is sent.

Consequence: Once rebalance_in_flight is stuck true, maybe_start_persist (core line 467) returns {State, []} unconditionally — no further manifests are ever persisted to Khepri. The replica reader continues uploading fragments to S3, but:

  • Replicas never learn about new fragments (broadcasts happen at persist time)
  • On node restart, all fragments uploaded since the last persist are orphaned in S3
  • Consumers reading from the remote tier see the stream stop growing

Fix: Replace spawn_link with spawn_monitor and add a {'DOWN', ...} handler that calls group_upload_failed, matching the pattern already used for the persist task (lines 679 + 406). Plain spawn (as used for delete_manifest_objects at line 499) removes the exit-signal hazard but does not solve the liveness problem — if the process dies without sending the result message, rebalance_in_flight is still stuck forever.

2. is_retriable/1 does not handle {http, 429} (Too Many Requests) (replica_reader_core.erl:631)

is_retriable({http, 500}) -> true;
is_retriable({http, 503}) -> true;
is_retriable(timeout) -> true;
is_retriable(_) -> false.

S3 returns 429 when rate-limited. Under the current code a 429 is a fatal error that drops the fragment and accepts a gap (for individual transfers at line 203) or abandons the rebalance (for group uploads at line 350). With the governor pacing uploads this scenario is less likely, but it can still happen when a bucket policy threshold is lower than the configured rate. A 429 should be retriable.

3. drain_completions/1 is unbounded recursive (replica_reader_core.erl:386)

drain_completions(State0) ->
    case queue:peek(Q) of
        {value, {Ref, _Meta}} ->
            case maps:get(Ref, PC, undefined) of
                {Uid, Meta} ->
                    ...
                    {State3, Effects} = drain_completions(State2),  %% recursive
                    ...

Each recursive call adds a stack frame. If a large batch of transfers completes concurrently (e.g. after a persist unblocks many that were queued), this recurses N times where N is the number of completed transfers. With the default persist_threshold = 5 this is bounded in normal operation, but if persist_threshold is set high or a burst of completions arrives post-reinitialize, the stack can grow large. Worth noting even if it does not cause problems at current thresholds.

4. resolve_manifest/1 uses catch instead of try/catch (replica_reader.erl:817,822)

case catch rabbitmq_stream_s3_db:get(StreamId) of
    {ok, #{revision := Rev}} -> M#manifest{revision = Rev};
    _ -> M
end;

Both branches of resolve_manifest/1 (lines 817 and 822) use bare catch. catch returns {'EXIT', {Reason, Stack}} for errors/exits, which silently matches _. Using try ... catch _:_ -> M end (or #manifest{} for the second branch) is the correct pattern and makes the fallback intentional rather than accidental.

5. remove_for_max_bytes/4 never removes the last root entry - comment should explain why (manifest.erl:195)

case Kind of
    ?MANIFEST_KIND_FRAGMENT when Rest =/= <<>> ->
        remove_for_max_bytes(Rest, TotalSize - Size, MaxBytes, N + 1);
    _ ->
        %% Hit a group entry or last entry. Stop here.
        N
end.

The Rest =/= <<>> guard (same in remove_for_max_age/3 at line 208) ensures the last fragment on the root is never deleted. The in-group variants (remove_for_max_bytes_in_group, remove_for_max_age_in_group) intentionally omit this guard (groups can be fully consumed). The current comment says "Hit a group entry or last entry. Stop here." - it should explain why we keep the last root entry (to avoid exposing an apparently empty stream while data still exists remotely but the manifest has been fully truncated).

6. get_meta_from_queue/2 has no base case for an empty list (replica_reader_core.erl:623)

get_meta_from_queue(Ref, [{Ref, Meta} | _]) -> Meta;
get_meta_from_queue(Ref, [_ | Rest]) -> get_meta_from_queue(Ref, Rest).

If Ref is not in the queue, this crashes with a function_clause error. This cannot currently happen because transfer_complete/3 and transfer_failed/3 are the only callers and they use refs that were previously inserted into the queue. But it is a fragile ordering assumption with no guard. A fallback clause with a meaningful error (e.g. error({unknown_ref, Ref})) would make failures diagnosable.


Code Quality and Style

  • The module docs (using -moduledoc) are excellent and detailed throughout.
  • Comments follow the project convention (above the line, not inline) correctly.
  • The comment on drain_completions/1 explaining the timer fallback for low-throughput streams is a good example of a non-obvious invariant explained well.
  • The ?ENTRY/6 binary macro with a matching ?ENTRY/7 pattern macro is clean and used consistently.
  • The counter_fields/0 export and the ?NODE_COUNTERS / ?STREAM_COUNTERS split for Prometheus monotonicity is well-documented inline.
  • Minor: src/rabbitmq_stream_s3_log_reader.erl:507 has a stale comment ("revise this comment as more moves into the server") that should be updated now that the server is gone.

Test Coverage

Coverage is good at the unit level:

  • replica_reader_core_SUITE (836 lines) covers all state-machine interleavings including out-of-order completions, threshold triggers, tick behavior, persist failures, rebalancing, and recursive rebalancing - this is the most important test suite.
  • fragment_iterator_SUITE, fragment_assembly_SUITE, prop_SUITE cover the pure modules well.
  • broker_SUITE provides end-to-end coverage.

Gaps:

  1. No test for the {http, 429} retriability gap noted above.
  2. No test for drain_completions with a large in-flight queue (stack depth concern).
  3. No test exercising the group-upload spawn_link crash path (it would hang rather than fail).

Summary

This is a high-quality rewrite that solves the stated problems cleanly. The core/shell split, the sequence-number broadcasting mechanism, and the functional test coverage are all well-implemented. Issues to address, in priority order:

  1. Must fix: Replace spawn_link with spawn_monitor + 'DOWN' handler for the group upload task (issue 1).
  2. Should fix: Add {http, 429} to is_retriable/1 (issue 2).
  3. Should fix: Replace catch with try/catch in resolve_manifest/1 (issue 4).
  4. Minor: Add a base case or meaningful error to get_meta_from_queue/2 (issue 6).
  5. Minor: Add a comment explaining why the last root entry is preserved (issue 5).
  6. Minor: Update the stale comment at log_reader.erl:507.

Review Session Context - PR #148

What this file is for

This file lets an AI agent resume the PR #148 review session on a different machine without losing context. Read this file, then read rabbitmq-stream-s3-gh-148.md for the actual review output.

PR under review

  • Repo: amazon-mq/rabbitmq-stream-s3 (private)
  • PR: #148 - "Redesign upload path to behave like replication"
  • Author: Michael Davis (the-mikedavis)
  • State: MERGED into main (decision: merge first, find bugs via continued testing)
  • Milestone: 1.0.0-beta.1
  • +13084 / -7364 lines across 70 files

Local branch location

The code is now on main in:

/home/lrbakken/development/amazon-mq/upstream-to-rabbitmq-server/deps/rabbitmq_stream_s3/

This is a git submodule (or nested repo) inside the main rabbitmq-server upstream repo at /home/lrbakken/development/amazon-mq/upstream-to-rabbitmq-server/. Current HEAD: 111845b.

The review document is at:

/home/lrbakken/development/lukebakken/gist/rabbitmq-stream-s3-gh-148/rabbitmq-stream-s3-gh-148.md

Post-merge bug fix commits

These were committed after the PR merge (f35e34d) before the current HEAD:

  • 1e62661 - Fix appended_since_persist being wiped on persist completion (fragments during in-flight persist lost)
  • c080ed3 - Fix metrics tracking for resubmitted fragments (retries not counted)
  • d075414 - Delete old manifests once new one is persisted
  • 5358236 - Fully reset reader after a conflict (reinitialize was incomplete)
  • 1cb82fb - Remove dead code from the old design (this is why the original review's issues 2/3 about apply_infos/2 and rebalance_edit/2 were invalid)

Files read during re-review (against HEAD 111845b)

All reads from deps/rabbitmq_stream_s3/ relative to the upstream repo root:

  • src/rabbitmq_stream_s3_replica_reader.erl - lines 240-270, 349-370, 405-435, 493-510, 640-690, 795-835
  • src/rabbitmq_stream_s3_replica_reader_core.erl - lines 190-215, 275-365, 380-420, 465-520, 620-636
  • src/rabbitmq_stream_s3_manifest.erl - lines 1-115, 186-215, 240-280
  • src/rabbitmq_stream_s3_hooks.erl - lines 46-70
  • src/rabbitmq_stream_s3_log_reader.erl - lines 500-510

Also read from deps/osiris/:

  • src/osiris_log.erl - lines 515-560 (to verify Config contents passed to hooks)

Key technical facts verified in source

These are facts confirmed by reading actual code at HEAD 111845b, not inferred:

Replica reader shell (replica_reader.erl)

  • Line 251: process_flag(trap_exit, true) - reader traps exits
  • Line 416: handle_info(_Info, State) -> {noreply, State} - unknown messages silently dropped
  • Line 659: spawn_link used for group upload task (BUG - see issue 1 in review)
  • Line 679: spawn_monitor used for persist task (correct)
  • Line 499: spawn used for delete_manifest_objects (correct)
  • Lines 817, 822: two bare catch rabbitmq_stream_s3_db:get(StreamId) calls (issue 4)

Core (replica_reader_core.erl)

  • Lines 631-635: is_retriable/1 - only 500, 503, timeout are retriable; 429 is not (BUG - issue 2)
  • Lines 623-624: get_meta_from_queue/2 has no base case for empty list (issue 6)
  • Line 386: drain_completions/1 is recursive, not iterative (issue 3)
  • Line 112: persist_threshold default is 5

Manifest (manifest.erl)

  • Lines 195/208: remove_for_max_bytes and remove_for_max_age both have Rest =/= <<>> guard preventing last root entry deletion (issue 5)

Hooks (hooks.erl)

  • Lines 61-62, 67-68: maps:get(shared, Config) and maps:get(dir, Config) without default — NOT a bug. Verified in osiris_log.erl:558 that osiris always injects shared before calling on_init, and dir is required by osiris's init/2 function head (line 515).

Stale comment

  • src/rabbitmq_stream_s3_log_reader.erl:507: "revise this comment as more moves into the server" - server is now deleted

What the review document contains

rabbitmq-stream-s3-gh-148.md contains the complete review. It has:

  • Overview section describing the rewrite
  • Architecture / Design section (strengths)
  • 6 numbered issues with code snippets and line references (all verified against HEAD 111845b)
  • Code quality and style observations
  • Test coverage assessment with gaps
  • Prioritized summary

Invalidated findings from original review (b7e892d)

These were in the first draft of the review but are no longer valid:

  1. Dead code apply_infos/2 and rebalance_edit/2 — removed in commit 1cb82fb before merge.
  2. on_init/3 maps:get without defaults — not a bug; osiris guarantees these keys are present (verified in osiris_log.erl:515,558).

What has NOT been done yet

  • The review has not been posted to GitHub. Since the PR is merged, the review should be filed as GitHub issues or discussed directly with the-mikedavis.
  • No fixes have been made — this is review-only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment