Skip to content

buffer: queue_size leaks permanently when chunk.purge fails after a successful write, eventually causing spurious BufferOverflowError #5468

Description

@Aneesh43

Describe the bug

In Fluent::Plugin::Buffer#purge_chunk, the queued byte counter (@queue_size_metrics) is only decremented inside the begin/rescue block, after chunk.purge succeeds. If chunk.purge raises, the rescue swallows the error and @queue_size_metrics.sub(bytesize) is skipped — but the chunk has already been removed from @Dequeued at the top of the method, so nothing ever retries the purge. The result is a permanent leak of @queue_size (and, if the failure was in the file unlink, an orphaned buffer file left on disk).

Because storable? is

def storable?
@total_limit_size > @stage_size_metrics.get + @queue_size_metrics.get
end
each failed purge ratchets @queue_size upward and never comes back down. Over a long-lived process this drifts toward total_limit_size, at which point storable? returns false and every subsequent emit raises Fluent::Plugin::Buffer::BufferOverflowError even though the real staged + queued data is small or empty. Input plugins then start rejecting incoming data (e.g. in_tail stops advancing), so records are dropped. Only a restart resets the counter.

This is the queue-side sibling of the staged-side accounting leak fixed in #5439. Same user-visible symptom — spurious BufferOverflowError on a near-empty buffer — but a different trigger and code path.

def purge_chunk(chunk_id)
metadata = nil
synchronize do
  chunk = @dequeued.delete(chunk_id)   # (1) removed from @dequeued up front
  return nil unless chunk

metadata = chunk.metadata
begin
  bytesize = chunk.bytesize
  chunk.purge                        # (2) can raise (unlink/close on the buffer path)
  @queue_size_metrics.sub(bytesize)  # (3) SKIPPED when (2) raises
rescue => e
  log.error "failed to purge buffer chunk", chunk_id: dump_unique_id_hex(chunk_id), error_class: e.class, error: e
  log.error_backtrace
end

@dequeued_num[chunk.metadata] -= 1
...
end
nil
end

If (2) raises:

(3) never runs → @queue_size is never decremented for those bytes.
The chunk is already gone from both @Queue (dequeued before the write) and @Dequeued (step 1) → no retry path, the leak is permanent.
If the raise came from the file unlink itself, the buffer file also stays on disk permanently.
To be precise about terminology: the leak is in the queued byte size (@queue_size / buffer_queue_byte_size), which is what storable? checks — not the queue element count.
If (2) raises:

(3) never runs → @queue_size is never decremented for those bytes.
The chunk is already gone from both @Queue (dequeued before the write) and @Dequeued (step 1) → no retry path, the leak is permanent.
If the raise came from the file unlink itself, the buffer file also stays on disk permanently.
To be precise about terminology: the leak is in the queued byte size (@queue_size / buffer_queue_byte_size), which is what storable? checks — not the queue element count.

To Reproduce

Self-contained script driving the real Buffer API (write → enqueue_chunk → dequeue_chunk → purge_chunk), with chunk.purge forced to raise to simulate an unlink/EIO failure on the buffer path. Deterministic — no flush-thread timing.

require 'fluent/version'
require 'fluent/test'
require 'fluent/test/driver/output'
require 'fluent/plugin/output'
require 'fluent/plugin/buffer'
require 'fluent/plugin/buffer/memory_chunk'

# simulate the physical purge (unlink/close) failing
module FailingPurge
  def purge; raise IOError, "simulated purge failure (unlink EIO)"; end
end
Fluent::Plugin::Buffer::MemoryChunk.prepend(FailingPurge)

class PurgeLeakOut < Fluent::Plugin::Output
  def format(tag, time, record); record['msg'] + "\n"; end
  def write(chunk); end   # write to the OUTPUT succeeds; only purge fails
end
Fluent::Plugin.register_output('purge_leak_out', PurgeLeakOut)

d = Fluent::Test::Driver::Output.new(PurgeLeakOut)
d.configure(%[
  <buffer>
    @type memory
    chunk_limit_size 1k
    total_limit_size 16k
    flush_at_shutdown false
  </buffer>
])
out = d.instance; out.start; buf = out.buffer

1.upto(40) do |i|
  begin
    meta = buf.metadata()
    buf.write({ meta => ["x" * 900 + "\n"] })  # stage ~900B (succeeds)
    buf.enqueue_chunk(meta)                     # staged -> queue (queue_size += ~900)
    c = buf.dequeue_chunk                       # queue -> dequeued (flush picks it up)
    buf.purge_chunk(c.unique_id) if c           # commit -> purge; chunk.purge raises
    note = ""
  rescue Fluent::Plugin::Buffer::BufferOverflowError
    note = "BufferOverflowError (buffer refuses write)"
  end
  real = buf.queue.map(&:bytesize).sum + buf.instance_variable_get(:@dequeued).values.map(&:bytesize).sum
  printf("iter=%-3d queue_size=%-6d real_queued=%-3d storable?=%-6s %s\n", i, buf.queue_size, real, buf.storable?, note)
end

Run it (no local ruby needed):

docker run --rm -v "$PWD:/t" ruby:3.2 sh -c \
  'gem install fluentd -v 1.19.3 --no-document -q; ruby /t/repro.rb'

Observed output (fluentd 1.19.3)

total_limit_size: 16384 bytes
iter=1   queue_size=901    real_queued=0   storable?=true
iter=2   queue_size=1802   real_queued=0   storable?=true
...
iter=18  queue_size=16218  real_queued=0   storable?=true
iter=19  queue_size=17119  real_queued=0   storable?=false
iter=20  queue_size=17119  real_queued=0   storable?=false  BufferOverflowError (buffer refuses write)
iter=21  queue_size=17119  real_queued=0   storable?=false  BufferOverflowError (buffer refuses write)
... (every subsequent emit raises)

queue_size climbs by each failed chunk's bytesize and never comes back down, while real_queued (the bytes actually held in @queue + @dequeued) stays 0. Once queue_size crosses total_limit_size, storable? is permanently false and every emit raises BufferOverflowError on an empty buffer. Each iteration also logs failed to purge buffer chunk. A restart clears it.

Moving @queue_size_metrics.sub(bytesize) so it runs even when chunk.purge fails (i.e. in an ensure) makes queue_size stay at 0 and the overflow never occurs — verified in the same harness.

fluentd-purge-leak-repro.zip

Expected behavior

A failed chunk.purge should not permanently inflate @queue_size. The queued bytes should be reconciled (decremented, or the purge retried/deferred) so that total_queued_size stays consistent with the chunks the buffer is actually holding, and storable? does not become permanently false due to accounting drift.

Your Environment

- Fluentd version:reproduced by source inspection on master; the purge_chunk code path is identical in v1.18.0, v1.19.0, v1.19.3
- Package version:
- Operating system:
- Kernel version:

Your Configuration

<system>
  log_level info
</system>

<source>
  @type sample
  tag test
  sample {"msg":"hello world hello world hello world"}
  rate 200
</source>

<match test>
  @type noop_format
  <buffer>
    @type file
    path /tmp/buf/test
    chunk_limit_size 1k
    total_limit_size 16k
    flush_mode immediate
    flush_thread_count 1
    retry_type periodic
    retry_wait 1s
    overflow_action throw_exception
    flush_at_shutdown false
  </buffer>
</match>

Your Error Log

# Trigger: chunk.purge raises; purge_chunk rescues it, so queue_size is never decremented
2026-08-10 08:43:50 +0000 [error]: #0 failed to purge buffer chunk chunk_id="658ad59b0aea892ad9316cb427274a28" error_class=IOError error=#<IOError: simulated purge failure (unlink EIO on buffer path)>
  2026-08-10 08:43:50 +0000 [error]: #0 /path/fail_purge.rb:9:in `purge'
  2026-08-10 08:43:50 +0000 [error]: #0 /usr/local/bundle/gems/fluentd-1.19.3/lib/fluent/plugin/buffer.rb:606:in `block in purge_chunk'
  2026-08-10 08:43:50 +0000 [error]: #0 /usr/local/bundle/gems/fluentd-1.19.3/lib/fluent/plugin/buffer.rb:597:in `purge_chunk'
  2026-08-10 08:43:50 +0000 [error]: #0 /usr/local/bundle/gems/fluentd-1.19.3/lib/fluent/plugin/output.rb:1139:in `commit_write'
  2026-08-10 08:43:50 +0000 [error]: #0 /usr/local/bundle/gems/fluentd-1.19.3/lib/fluent/plugin/output.rb:1260:in `try_flush'
  2026-08-10 08:43:50 +0000 [error]: #0 /usr/local/bundle/gems/fluentd-1.19.3/lib/fluent/plugin/output.rb:1571:in `flush_thread_run'

# Consequence: leaked queue_size crosses total_limit_size -> storable? false -> every emit rejected
2026-08-10 08:43:50 +0000 [warn]: #0 failed to write data into buffer by buffer overflow action=:throw_exception
2026-08-10 08:43:50 +0000 [warn]: #0 emit transaction failed: error_class=Fluent::Plugin::Buffer::BufferOverflowError error="buffer space has too many data" location="/usr/local/bundle/gems/fluentd-1.19.3/lib/fluent/plugin/buffer.rb:335:in `write'" tag="test"
  2026-08-10 08:43:50 +0000 [warn]: #0 /usr/local/bundle/gems/fluentd-1.19.3/lib/fluent/plugin/output.rb:921:in `emit_buffered'
  2026-08-10 08:43:50 +0000 [warn]: #0 /usr/local/bundle/gems/fluentd-1.19.3/lib/fluent/plugin/in_sample.rb:115:in `emit'

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions