Skip to content

OOM risk: put_persisted_messages bypasses max_memory_mb limit on startup #2128

@juanherrera-jpy

Description

@juanherrera-jpy

Summary

When a sink accumulates a large number of persisted events in the database (e.g., due to a delivery error), all events are loaded into memory unconditionally on startup/rehydration via put_persisted_messages, bypassing max_memory_mb and setting_max_messages limits. This can cause OOM kills on the pod.

Reproduction scenario

We experienced this in production with an Elasticsearch OpenSearch sink (jobs_sink) that had a strict_dynamic_mapping_exception delivery error. While the sink was erroring:

  1. 140,416 events accumulated in sequin_streams.consumer_events (partition 68)
  2. Each enriched Job message was ~18 KB in memory (enrichment joins ~12 related tables)
  3. When the delivery error was fixed, the SlotMessageStore loaded all persisted messages at once
  4. Memory jumped to 1,509 MB despite max_memory_mb being set to 512 MB
  5. On an earlier run with max_memory_mb=128, the store oscillated between filling (hitting the limit for new WAL messages) and draining, but persisted messages were always loaded without limit

Root cause

In lib/sequin/runtime/slot_message_store_state.ex:

# Line 132-143
def put_persisted_messages(%State{} = state, messages) do
    persisted_message_groups =
      Enum.reduce(messages, state.persisted_message_groups, fn msg, acc ->
        Multiset.put(acc, group_id(msg), {msg.commit_lsn, msg.commit_idx})
      end)

    # This cannot fail because we `skip_limit_check?`
    {:ok, state} =
      put_messages(%{state | persisted_message_groups: persisted_message_groups}, messages, skip_limit_check?: true)

    state
end

Both put_persisted_messages (line 141) and put_table_reader_batch (line 160) call put_messages with skip_limit_check?: true, meaning:

  • max_memory_mb is not enforced
  • setting_max_messages (default 50,000) is not enforced
  • There is no upper bound on memory consumption during rehydration

Impact

  • OOM risk: A pod with 32 GB RAM could be killed if enough events accumulate. In our case, 140k events at ~18 KB each = ~2.5 GB. A table with larger payloads or more accumulated events could easily exceed pod memory limits.
  • No recovery path: Once events are persisted, every restart will attempt to load them all, potentially causing repeated OOM crashes.
  • Silent accumulation: Events pile up silently when a sink has delivery errors. There's no alert or limit on how many events can accumulate in the database.

Environment

  • Sequin self-hosted on Kubernetes (EKS)
  • Pod resources: 8 CPU / 32 GB RAM
  • 25 Elasticsearch OpenSearch sinks, all using enrichment queries
  • Source database: Aurora PostgreSQL 12
  • Sink consumer config: batch_size=1, max_memory_mb=128 (later increased to 512), message_grouping=true

Suggested improvements

  1. Batch-load persisted messages: Instead of loading all persisted messages at once, load them in configurable batches that respect max_memory_mb, processing each batch before loading the next.

  2. Enforce a hard memory ceiling: Even for persisted messages, don't exceed a configurable absolute maximum (e.g., max_memory_mb * 2 or a separate hard_max_memory_mb setting).

  3. Add a persisted event count limit or alert: When events accumulate beyond a threshold in the database (e.g., due to sustained delivery errors), surface a health check warning or automatically pause the sink before the backlog becomes dangerous.

  4. Consider on-demand loading: Instead of eagerly loading all persisted messages into memory, load them on-demand as the sink has capacity to deliver, similar to how the ConsumerProducer pulls from the store.

Workarounds

Currently the only options to prevent OOM are:

  • Fix delivery errors quickly before events accumulate
  • Manually delete stuck events from sequin_streams.consumer_events
  • Ensure pod memory limits are very generous relative to potential event backlogs

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions