Skip to content

Bug: Missed :flushed notification from WriteLoop to Consumer blocks WAL acknowledgment #4058

Closed

Description

@alco

Summary

Under certain conditions, the {Storage, :flushed, offset} message sent by WriteLoop.flush_buffer/2 at write_loop.ex:329 is lost and never received by the Consumer process. This prevents the FlushTracker from advancing, which blocks WAL acknowledgment to Postgres, causing unbounded WAL retention and replication slot bloat.

Impact

A single stuck shape consumer blocks the global flush offset for the entire stack. All other shapes can be fully caught up, but the FlushTracker's global minimum is pinned to the stuck shape's last acknowledged offset. Postgres WAL accumulates indefinitely until the issue is resolved or the service is restarted.

In the observed incident, 1.72 GiB of WAL backlog accumulated before manual intervention.

Affected Code Path

flush_buffer/2 (write_loop.ex:314-342)
  -> IO.binwrite(json_file, buffer)
  -> :file.datasync(json_file)
  -> send(self(), {Storage, :flushed, last_seen_offset})    <-- message is lost

Consumer.handle_info({Storage, :flushed, offset}, state)    <-- never fires
  -> align_offset_to_txn_boundary/2
  -> ShapeLogCollector.notify_flushed/3

Conditions That Trigger the Bug

The observed Consumer had these characteristics:

  • Very small data volume: ~540 bytes written from a single matching operation in a 47,520-op transaction
  • Buffer never hit the 64KB inline-flush threshold: the only flush was deferred (scheduled timer or hibernation)
  • write_unit: :txn_fragment: fragment-based write mode
  • Post-flush state: times_flushed: 1, all WriteLoop offsets aligned, open_files: {nil, nil}, buffer_size: 0

The bug appears to be rare and associated with shapes that have very little streaming data (small enough that the buffer is only flushed during hibernation, not inline during writes).

Hypotheses for Root Cause

Hypothesis 1: Message lost during OTP hibernate transition (most likely)

The Consumer's handle_info(:timeout, ...) calls Storage.hibernate() -> flush_and_close_all -> flush_buffer, which calls send(self(), {Storage, :flushed, offset}). The callback then returns {:noreply, state, :hibernate}.

The BEAM's erlang:hibernate/3 reclaims the process heap. While the message should survive in the mailbox (messages are stored outside the heap), there may be an edge case:

  • The send(self(), ...) is executed during the callback. The message is placed in the process's own mailbox.
  • The callback returns :hibernate. The OTP gen_server loop calls proc_lib:hibernate/3.
  • If there is any window between the message being enqueued and the process entering the hibernate wait state where the message reference could be invalidated, the message could be lost.

This would be an OTP/BEAM-level bug and would be extremely rare, which matches our observation of it happening to only one shape.

Hypothesis 2: Scheduled flush timer cancelled before firing

  1. append_fragment_to_log! writes data to the buffer and returns {acc, schedule_flush: N}.
  2. A flush timer is scheduled for 1 second.
  3. Before the timer fires, another event arrives that triggers a flush with cancel_flush_timer: true (if the buffer was drained during that write).
  4. The timer is cancelled at pure_file_storage.ex:1208-1209.
  5. The inline flush during the second write sends :flushed with a different offset (from the second write).
  6. When the Consumer processes this :flushed, align_offset_to_txn_boundary drops entries with offsets less than the flushed offset, silently consuming the original mapping entry without ever calling notify_flushed for its boundary.

However, this hypothesis is weakened by the Consumer's state showing only one txn_offset_mapping entry and no evidence of subsequent writes.

Hypothesis 3: Race with signal_txn_commit!

In the write_unit: :txn_fragment path, append_fragment_to_log! and signal_txn_commit! are called separately. If the scheduled flush fires between these two calls (while the Consumer is processing the commit fragment in maybe_complete_pending_txn):

  1. append_fragment_to_log! writes data, schedules a flush timer.
  2. The handle_call for the commit fragment begins. The Consumer is processing synchronously.
  3. The flush timer fires, but since the Consumer is in a handle_call, the timer message waits in the mailbox.
  4. signal_txn_commit! runs, updates last_seen_txn_offset.
  5. maybe_complete_pending_txn adds the txn_offset_mapping entry.
  6. handle_call returns.
  7. The timer message is processed: perform_scheduled_flush -> flush_buffer -> sends :flushed.
  8. :flushed is processed -> align_offset_to_txn_boundary runs.

This sequence should still work correctly because step 7 and 8 happen in order. Unless there's a state inconsistency where flush_buffer sees stale offsets.

Hypothesis 4: flush_buffer no-op on empty buffer after prior drain

If the buffer was somehow drained by a path that does NOT send :flushed (e.g. an ETS-only write, or a code path that clears the buffer without going through flush_buffer), then subsequent calls to flush_buffer would match the buffer_size: 0 clause at write_loop.ex:310-312 and return without sending the notification.

The flush_and_close_all at write_loop.ex:78-79 unconditionally calls flush_buffer. If the buffer was already empty, this is a no-op -- no :flushed message is sent. The only flush that occurred (the one that set times_flushed: 1) would have sent :flushed, but if that message was lost or preempted, no subsequent flush would ever re-send it.

This is the most mechanically plausible explanation: once the :flushed message is lost for any reason, there is no recovery path -- no code ever re-sends it.

Proposed Solutions

Fix 1: Synchronous flush notification during hibernation (targeted fix)

Instead of relying on the async send(self(), ...) during flush_and_close_all, have the Consumer's hibernate path call notify_flushed directly after Storage.hibernate() returns:

# In Consumer.handle_info(:timeout, ...)
def handle_info(:timeout, state) do
  if consumer_suspend_enabled?(state) and consumer_can_suspend?(state) do
    {:stop, ShapeCleaner.consumer_suspend_reason(), state}
  else
    state = %{state | writer: ShapeCache.Storage.hibernate(state.writer)}
    # Drain any pending flush notifications synchronously
    state = flush_pending_notifications(state)
    {:noreply, state, :hibernate}
  end
end

Where flush_pending_notifications checks if the WriteLoop has advanced past the last notified offset and calls notify_flushed directly.

Fix 2: Return flush offset from flush_and_close_all (structural fix)

Change flush_and_close_all to return the flushed offset so the caller can act on it synchronously, rather than relying on a self-sent message:

def flush_and_close_all(acc, state) do
  {acc, flushed_offset} = flush_buffer_returning_offset(acc, state)
  # ... close files ...
  {writer_acc(acc, open_files: open_files()), flushed_offset}
end

The Consumer (or Storage.hibernate) would then handle the notification inline.

Fix 3: Periodic flush consistency check (defense in depth)

Add a periodic check (e.g. on the hibernate timeout or a dedicated timer) that compares the WriteLoop's last_persisted_offset against the last offset reported to the FlushTracker. If they diverge, re-send the :flushed notification. This acts as a self-healing mechanism regardless of the root cause.

Fix 4: Decouple flush_buffer notification from send(self(), ...) (cleanest fix)

Instead of having flush_buffer use send(self(), ...) as a side effect buried inside a pure-looking function, have it return the flushed offset as part of its return value. All callers would then be responsible for propagating the notification, making the data flow explicit and eliminating the reliance on message passing within a single process:

def flush_buffer(writer_acc(buffer_size: 0) = acc, _state) do
  {acc, _flushed_offset = nil}
end

def flush_buffer(acc, state) do
  # ... write + fsync ...
  {updated_acc, _flushed_offset = last_seen_offset}
end

This makes it impossible for the notification to be lost, since it's part of the function's return value, not a side-effecting message.

Activity

  1. alco commented on Mar 25, 2026

    @alco
    ContributorAuthor

    I have managed to unblock one customer in prod by just sending its consumer process the correct :flushed message to trigger the cleanup of txn_offset_mapping and dispatch a flush notification to SLC. After that, its flush tracker and replication client state got caught up immediately.

  2. alco commented on Mar 25, 2026

    @alco
    ContributorAuthor

    Second incident: 32 shapes stuck on same transaction

    Observed the same bug on a different stack, but at much larger scale: 32 shape consumers all stuck on the same transaction (tx_offset: 8458315976, 1066 ops), each with a different last_flushed op offset but all with last_sent at op 1066.

    Key observations

    • All 32 consumers showed the identical stuck pattern: idle process, empty mailbox, txn_offset_mapping with one unconsumed entry, open_files: {nil, nil}, data fully persisted in WriteLoop.
    • times_flushed values were 58 and 61 on the two spot-checked consumers — these shapes had been flushing successfully for a long time before this particular transaction's notification was lost.
    • The ShapeLogCollector was idle (status: :waiting, message_queue_len: 0) — not blocked mid-dispatch.
    • 2.63 GiB of WAL backlog accumulated before manual intervention.

    What this tells us about root cause

    All 32 shapes received the same transaction, each processed their portion (different last_flushed offsets reflect different amounts of matching data), and then all 32 lost their :flushed message. This is very unlikely to be 32 independent random failures.

    The most plausible explanation: after processing the transaction, all 32 consumers went idle for 30 seconds (hibernate_after: 30000), then all hit the hibernate timeout in quick succession. The flush_and_close_all path flushed each consumer's buffer and called send(self(), {Storage, :flushed, offset}), but the message was lost during the :hibernate transition in every case.

    This strengthens Hypothesis 1 (message lost during OTP hibernate transition) and makes it look less like a rare one-off and more like a systematic issue: any consumer whose only pending flush happens during the hibernation path will lose the notification.

    Resolution

    Same batch fix as the first incident — verified storage persistence via ETS, then sent the missing :flushed message to each consumer. All 32 recovered immediately, flushed_wal caught up to received_wal, and flush_up_to_date? flipped to true.

  3. self-assigned this
    on Mar 25, 2026
  4. alco commented on Mar 25, 2026

    @alco
    ContributorAuthor

    Shared observation from multiple customers expericing stuck FlushTracker today: consumer state shows pending_txn: nil but its txn_offset_mapping has two entries, e.g. {LogOffset.new(54926778248, 0), LogOffset.new(54926778248, 47520)}.

    This looks to be incorrect handling of multi-fragment txns in consumer or a timing issue between the flush messages emitted by PureFileStorage and the processing of the commit fragment.

  5. alco commented on Mar 26, 2026

    @alco
    ContributorAuthor

    Closing in favor of #4063 which documents the precise root cause found during investigation. The :flushed message is not lost — it arrives and is processed, but at the wrong time relative to the commit fragment. When storage flushes before txn_offset_mapping is populated (because the commit hasn't been processed yet), align_offset_to_txn_boundary returns a raw, unaligned offset. FlushTracker then gets a notification that can never match last_sent, permanently blocking the shape.

    A deterministic regression test is on branch alco/flush-tracker-notification-timing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions