Skip to content

RepartitionExec channels grow unboundedly with one slow consumer #22090

Open

Description

@JanKaul

Describe the bug

RepartitionExec's distribution channels (distributor_channels.rs) only throttle producers when every output channel has at least one buffered item ("Gate A"). With balanced hash partitioning and a single slow consumer, the slow channel grows linearly per input batch.

To reproduce

Trace with N=4 partitions, ch3's consumer slower than the others:

  1. After the first input batch each channel has ≥1 item → Gate A closes.
  2. ch0 drains → Gate A opens.
  3. Producer pushes the next input batch's sub-batches: one take-materialized batch per partition. Push to ch0 succeeds (was empty); ch1, ch2, ch3 already had data, so the empty-counter never decrements during the round.
  4. After the round all channels are non-empty again → Gate A re-closes. ch3 has gained 1.

Each cycle adds another batch to ch3 — linear growth in the number of input batches, until OOM (or until the per-partition SharedMemoryReservation triggers spilling).

Cause

Gate A's invariant is "no live channel is empty." A single fast channel oscillating empty/non-empty keeps the gate open forever, and every gate-open window lets the producer push to every partition — including the slow one. Skewed input data is not required; even hash distribution + one lagging consumer is enough.

Expected behavior

The producer should be throttled when total buffered memory crosses a configured threshold, regardless of how many channels are technically non-empty.

Proposed fix

Add a second gate condition — total buffered bytes across all channels ≥ a configured budget — and close the gate when either condition fires (A || B). Gate A still gives O(1) per-channel depth for balanced workloads with even consumer rates; Gate B caps total memory whenever some channel never drains. Velox's LocalExchangeMemoryManager is the design template.

I'll send a PR shortly.

Activity

  1. alamb commented on May 10, 2026

    @alamb
    Contributor

    RepartitionExec's distribution channels (distributor_channels.rs) only throttle producers when every output channel has at least one buffered item

    This is by design to avoid deadlocks

    The producer should be throttled when total buffered memory crosses a configured threshold, regardless of how many channels are technically non-empty.

    If you have a situation where one of the channels is empty, are you guaranteed that the other non empty channels can make progress? For example the classic diamond plan

    SortPreservingMerge (or some other operator                
          where consumption is a function                      
           of the values in the streams)                       
                                                               
           ┌──────────────────────┐                            
           │         Merge        │                            
           └──────────────────────┘                            
               ▲       ▲      ▲                                
               │       │      │                                
               │       │      │                                
               │       │      │                                
               │       │      │                                
               │       │      │                                
               │       │      │                                
               │       │      │                                
               │       │      │                                
          ┌───┐│       │      │ ┌───┐                          
          │   ││       │      │ │   │                          
          │   ││       │      │ │   │ Channel 1 and 3 are full 
          └───┘│       │      │ └───┘       / memory full      
           ┌───┴───────┴──────┴───┐   but a batch is needed in 
           │      Repartition     │       Channel 2 to make    
           └──────────────────────┘           progress         
                                                               
    

    If you have one consumer falling behind, I think better strategy might be to apply back pressure at the consumer end (rather than the Repartition)

    What are the consumers in this case?

  2. JanKaul commented on May 10, 2026

    @JanKaul
    ContributorAuthor

    Thanks for the quick reply! Let me try to defend my position for the sake of exploring the solution space.

    With my proposal, the buffered bytes are capped at a certain value, but every time a record batch is processed by a consumer, its bytes are released, freeing up headroom. This essentially means the RepartitionExec stays "open" as long as it receives and releases memory at roughly the same rate. So yes, it can theoretically lead to more deadlocks.
    That said, the situation under which it "locks" is generally an undesirable one to begin with: it's the case where a RepartitionExec continuously receives more bytes than it can release. This can of course be legitimately required by certain downstream consumers, as your example shows. But there is typically a limit to how much data downstream consumers need in order to make progress, and that's what this cap is meant to enforce. The cap could be rather large, allowing for the scenarios you mentioned. I'd argue that if the unhealthy state of receiving more than is being released persists for too long, it eventually leads to OOM, which isn't much better than a deadlock. The problem is that the higher memory consumption of RepartitionExec leads to even slower downstream consumers because they are forced to spill which leads to some kind of self-amplification effect.

    Again, thanks a lot for taking the time to explain. I'll try to dig deeper into it and see if there are other possibilities to apply more backpressure. Like you suggested.

    A bit of context of what I'm trying to achieve. So I'm trying to run TPCH query 17 sf=100 in a memory constrained environment with either 4gb or 8gb of memory. I'm using SortMergeJoin as the Join algorithm. And I'm experimenting with diffent(also custom) memory pools.

    The query is:

    select
    	sum(l_extendedprice) / 7.0 as avg_yearly
    from
    	embucket.tpch.lineitem,
    	embucket.tpch.part
    where
    	p_partkey = l_partkey
    	and p_brand = 'Brand#42'
    	and p_container = 'LG BAG'
    	and l_quantity < (
    		select
    			0.2 * avg(l_quantity)
    		from
    			embucket.tpch.lineitem
    		where
    			l_partkey = p_partkey
    	);

    I run into the problem that only one ExternalSorter partition continues working and 5.3gb of my 8gb memory pool is stuck in the RepartitionExec.

      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 254017536 (was 253624320) state=0                                                                                                                                                                         
      [trace] tc.grow name="RepartitionExec[14]" tid=ThreadId(7) +12408 -> reserved 389708208 (was 389695800) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[15]" tid=ThreadId(7) +12384 -> reserved 175256112 (was 175243728) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[1]" tid=ThreadId(3) +13512 -> reserved 513931128 (was 513917616) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[2]" tid=ThreadId(3) +13632 -> reserved 512652240 (was 512638608) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[3]" tid=ThreadId(3) +12720 -> reserved 357972600 (was 357959880) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[4]" tid=ThreadId(3) +12360 -> reserved 512861352 (was 512848992) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[5]" tid=ThreadId(3) +11688 -> reserved 358234632 (was 358222944) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[6]" tid=ThreadId(3) +11904 -> reserved 358406016 (was 358394112) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[7]" tid=ThreadId(3) +13392 -> reserved 378418200 (was 378404808) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[8]" tid=ThreadId(3) +12552 -> reserved 513046896 (was 513034344) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[9]" tid=ThreadId(3) +11664 -> reserved 481272456 (was 481260792) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[10]" tid=ThreadId(3) +13128 -> reserved 513369552 (was 513356424) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[11]" tid=ThreadId(3) +11832 -> reserved 11832 (was 0) state=0                                                                                                                                                                                      
      [trace] tc.grow name="RepartitionExec[12]" tid=ThreadId(3) +12816 -> reserved 175500096 (was 175487280) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[1]" tid=ThreadId(5) +12600 -> reserved 513956712 (was 513944112) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[1]" tid=ThreadId(16) +12984 -> reserved 513904992 (was 513892008) state=0                                                                                                                                                                          
      [trace] tc.shrink name="RepartitionExec[11]" tid=ThreadId(4) -12096 -> reserved 0 (was 12096) state=0                                                                                                                                                                                    
      [trace] tc.grow name="RepartitionExec[1]" tid=ThreadId(14) +12624 -> reserved 513917616 (was 513904992) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[2]" tid=ThreadId(14) +14112 -> reserved 512678784 (was 512664672) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[3]" tid=ThreadId(14) +12960 -> reserved 357985560 (was 357972600) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[4]" tid=ThreadId(14) +12960 -> reserved 512874312 (was 512861352) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[14]" tid=ThreadId(11) +12360 -> reserved 389720568 (was 389708208) state=0                                                                                                                                                                         
      [trace] tc.grow name="RepartitionExec[0]" tid=ThreadId(6) +12624 -> reserved 378636912 (was 378624288) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[2]" tid=ThreadId(2) +11424 -> reserved 512638608 (was 512627184) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[14]" tid=ThreadId(9) +13176 -> reserved 389695800 (was 389682624) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[1]" tid=ThreadId(17) +12984 -> reserved 513944112 (was 513931128) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[14]" tid=ThreadId(8) +12408 -> reserved 389732976 (was 389720568) state=0                                                                                                                                                                          
      [trace] tc.shrink name="RepartitionExec[11]" tid=ThreadId(4) -11832 -> reserved 0 (was 11832) state=0                                                                                                                                                                                    
      [trace] tc.grow name="RepartitionExec[13]" tid=ThreadId(16) +12960 -> reserved 36493536 (was 36480576) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[14]" tid=ThreadId(5) +11736 -> reserved 389744712 (was 389732976) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[2]" tid=ThreadId(3) +12432 -> reserved 512664672 (was 512652240) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[13]" tid=ThreadId(15) +12840 -> reserved 36480576 (was 36467736) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[0]" tid=ThreadId(7) +12336 -> reserved 378649248 (was 378636912) state=0                                                                                                                                                                           
      [trace] tc.grow name="RepartitionExec[4]" tid=ThreadId(12) +12264 -> reserved 512848992 (was 512836728) state=0                                                                                                                                                                          
      [trace] tc.grow name="RepartitionExec[4]" tid=ThreadId(10) +12264 -> reserved 512836728 (was 512824464) state=0                                                                                                                                                                          
      [trace] tc.shrink name="ExternalSorter[13]" tid=ThreadId(13) -254017536 -> reserved 0 (was 254017536) state=0                                                                                                                                                                            
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 393216 (was 0) state=0                                                                                                                                                                                    
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 786432 (was 393216) state=0                                                                                                                                                                               
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 1179648 (was 786432) state=0                                                                                                                                                                              
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 1572864 (was 1179648) state=0                                                                                                                                                                             
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 1966080 (was 1572864) state=0                                                                                                                                                                             
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 2359296 (was 1966080) state=0                                                                                                                                                                             
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 2752512 (was 2359296) state=0                                                                                                                                                                             
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 3145728 (was 2752512) state=0                                                                                                                                                                             
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 3538944 (was 3145728) state=0                                                                                                                                                                             
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 3932160 (was 3538944) state=0                                                                                                                                                                             
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 4325376 (was 3932160) state=0                                                                                                                                                                             
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 4718592 (was 4325376) state=0                                                                                                                                                                             
      [trace] tc.grow name="ExternalSorter[13]" tid=ThreadId(13) +393216 -> reserved 5111808 (was 4718592) state=0
    
  3. alamb commented on May 11, 2026

    @alamb
    Contributor

    Thanks for the quick reply!

    No problem!

    With my proposal, the buffered bytes are capped at a certain value, but every time a record batch is processed by a consumer, its bytes are released, freeing up headroom. This essentially means the RepartitionExec stays "open" as long as it receives and releases memory at roughly the same rate. So yes, it can theoretically lead to more deadlocks.

    This is not just a theory -- we have seen such deadlocks in production at InfluxData -- typically on very skewed datasets

    That said, the situation under which it "locks" is generally an undesirable one to begin with: it's the case where a RepartitionExec continuously receives more bytes than it can release.

    I agree the situation is undesirable, though I disagree with the root cause. The root cause is that there is some downstream operator consuming partitions at very different rates

    This can of course be legitimately required by certain downstream consumers, as your example shows. But there is typically a limit to how much data downstream consumers need in order to make progress, and that's what this cap is meant to enforce.

    I don't see how the cap will ensure that the downstream consumer can make progress

    A bit of context of what I'm trying to achieve. So I'm trying to run TPCH query 17 sf=100 in a memory constrained environment with either 4gb or 8gb of memory. I'm using SortMergeJoin as the Join algorithm. And I'm experimenting with diffent(also custom) memory pools.

    I wonder if the problem is that some of the SMJ partitions have chosen to spill but others have not (yet) resulting in different rates... maybe you could look into forcing all SMJ partitions to spill if any does

  4. adriangb commented on May 11, 2026

    @adriangb
    Contributor

    Could an a answer be to tweak how RepartitionExec itself spills? I.e. instead of accumulating data in memory it spills when there's a lot of skew. I think it already spills in some conditions (memory pressure?) but maybe it should spill more aggressively (e.g. only keep 1 batch in memory at a time).

    Related: if the partition that is spilling is because it's upstream operator is slow because the upstream operator spilled, I wonder if it would be beneficial to have a enum InFlightData { Memory(RecordBatch), Disk { file, start, end } or something like that. The point is: if we are going from one spilling operator to another maybe pushing the data around on disk instead of loading only to spill it again would make sense. But that'd be a big change.

  5. JanKaul commented on May 11, 2026

    @JanKaul
    ContributorAuthor

    This is not just a theory -- we have seen such deadlocks in production at InfluxData -- typically on very skewed datasets

    All right, thanks for the input. I thought this would generally be a good idea, but I don't have as much experience with deadlocks. I'll focus on solving the problem downstream such that the consumers work more uniformly.

    Related: if the partition that is spilling is because it's upstream operator is slow because the upstream operator spilled, I wonder if it would be beneficial to have a enum InFlightData { Memory(RecordBatch), Disk { file, start, end } or something like that. The point is: if we are going from one spilling operator to another maybe pushing the data around on disk instead of loading only to spill it again would make sense. But that'd be a big change.

    Passing the spilled files from one operator to another would only work if the operator doesn't change the data, which is probably not that often.

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

Metadata

Metadata

Assignees

No one assigned

    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