Preventing Data Loss on Buffer Flush Failure
A Buffer table holds its accumulated rows entirely in RAM until a flush writes them to the destination MergeTree, which means an unclean restart, an OOM kill, or a flush that keeps failing silently discards every row in that in-memory window. The buffer offers throughput, not durability, and treating it as durable is how a pipeline loses an hour of events without a single error in the query log. This guide quantifies that exposure window and closes it with external offset tracking, a bounded staleness cap, replay on restart, and a circuit breaker that stops writers before the ceiling blocks them.
Durability has to live outside the buffer. The pattern is at-least-once delivery gated on flush confirmation: an upstream source commits its position (a Kafka offset, a Redis high-water mark) only after the rows it produced are known to be on disk in the destination, so a crash replays the uncommitted tail instead of losing it. Everything below builds that guarantee around a buffer that itself remains deliberately volatile.
Prerequisites
The exposure window
Every row lives through a window where it has been acknowledged to the writer but not yet persisted: it sits in a buffer layer between the insert’s HTTP 200 and the layer’s flush. A crash anywhere in that window loses those rows. The width of the window is bounded by max_time and by how full the layers are — which is precisely why durability depends on when you commit the upstream offset, not on the buffer at all.
Step 1 — Quantify the current exposure
You cannot bound a window you have not measured. Read how many rows are resident and how long they have been waiting; multiply by your insert rate to see how much a crash would cost right now.
SELECT metric, value
FROM system.asynchronous_metrics
WHERE metric LIKE '%Buffer%'
ORDER BY metric;
...BufferRows (or the buffer-specific metrics your build exposes) is the count that would vanish on an unclean stop. Compare buffer-visible rows against what is actually on disk to see the in-memory delta directly:
SELECT
(SELECT count() FROM analytics.events_buffer) AS via_buffer,
(SELECT count() FROM analytics.events_raw) AS on_disk,
(SELECT count() FROM analytics.events_buffer)
- (SELECT count() FROM analytics.events_raw) AS at_risk;
at_risk is the exact number of rows a crash would lose at this instant. Track it over time — a growing at_risk under steady load means flushes are falling behind.
Step 2 — Gate offset commits on flush confirmation
The core fix: never advance the upstream position until the rows behind it are on disk. After inserting a batch, force the layers holding it to drain, then commit the offset. OPTIMIZE TABLE on a buffer flushes synchronously and returns only when the destination has the rows.
import clickhouse_connect
client = clickhouse_connect.get_client(host="clickhouse", database="analytics")
def insert_and_commit(rows, columns, kafka_consumer, batch):
# 1. Write to the volatile buffer.
client.insert("events_buffer", rows, column_names=columns)
# 2. Force the buffer to persist before we advance the source position.
client.command("OPTIMIZE TABLE analytics.events_buffer")
# 3. Only now is it safe to commit — a crash before this replays the batch.
kafka_consumer.commit(message=batch[-1])
Draining on every batch trades some of the buffer’s coalescing benefit for durability. In high-throughput pipelines, commit on a cadence instead: flush and commit every few seconds so at most one interval’s worth of offsets is ever uncommitted. The consumer-group offset mechanics for the Kafka case are covered in configuring Kafka consumer groups for ClickHouse.
Step 3 — Cap staleness with max_time
If you would rather not force-drain per batch, bound the exposure window structurally by shrinking max_time. A layer then flushes on its own within that ceiling regardless of row count, so the worst-case loss is one max_time interval of ingest.
-- Recreate with a tight staleness cap. A Buffer cannot be ALTERed in place.
DROP TABLE IF EXISTS analytics.events_buffer;
CREATE TABLE analytics.events_buffer AS analytics.events_raw
ENGINE = Buffer(
'analytics', 'events_raw',
16,
5, -- min_time (s)
15, -- max_time (s): worst-case loss window shrinks to 15s
100000, 1000000,
10485760, 268435456
);
Lowering max_time narrows the exposure window but flushes smaller blocks during quiet periods, so it trades durability against part size. Keep it as tight as your part-size budget allows and commit offsets no earlier than max_time after the corresponding insert.
Step 4 — Add a circuit breaker on flush failure
The buffer blocks inserts once max_bytes is reached and retries failed flushes silently — rows pile up in RAM while writers keep succeeding until the ceiling hits. A circuit breaker watches resident memory and stops writers before that point, holding uncommitted offsets upstream instead of losing them.
def buffer_at_risk_bytes(client) -> int:
row = client.query(
"SELECT value FROM system.asynchronous_metrics "
"WHERE metric = 'MemoryTracking'"
).result_rows
return int(row[0][0]) if row else 0
MAX_BYTES_CEILING = 4 * 1024**3 # matches num_layers * max_bytes
TRIP_AT = int(0.7 * MAX_BYTES_CEILING) # break at 70% of the ceiling
def guarded_insert(client, rows, columns):
if buffer_at_risk_bytes(client) > TRIP_AT:
# Do NOT insert or commit — let the source retain the offset and back off.
raise RuntimeError("buffer near ceiling; pausing ingest to avoid loss")
client.insert("events_buffer", rows, column_names=columns)
Because the writer refuses to insert (and therefore does not commit the offset) when the buffer is stressed, a subsequent crash replays cleanly from the last committed position. This is the same backpressure discipline the queue maxsize provides in the async writer pattern, applied to durability rather than throughput.
Step 5 — Replay the uncommitted tail on restart
On startup, the writer resumes from the last committed offset, which is at or behind the last durable row. At-least-once means some already-persisted rows get re-inserted, so the destination must absorb duplicates. Point replay at a ReplacingMergeTree keyed on a source id, or enable insert deduplication so identical blocks are dropped.
-- Confirm replay did not double-count by comparing distinct source ids to total rows.
SELECT
count() AS total_rows,
uniqExact(source_event_id) AS distinct_events,
count() - uniqExact(source_event_id) AS duplicates_pending_merge
FROM analytics.events_raw
WHERE event_ts > now() - INTERVAL 1 HOUR;
duplicates_pending_merge above zero right after a restart is expected under at-least-once — a ReplacingMergeTree collapses them on the next merge. A persistently climbing figure means dedup is not configured correctly.
Verification
Simulate the failure. Insert a known batch, do not flush, and confirm the destination has not yet received it — those are the rows a crash would lose.
-- Rows visible through the buffer but not yet durable on disk.
SELECT
(SELECT count() FROM analytics.events_buffer) AS visible,
(SELECT count() FROM analytics.events_raw) AS durable;
Then drain and re-check: after OPTIMIZE TABLE analytics.events_buffer, durable should equal visible, proving the flush persisted everything. Track resident memory continuously with the counters in tracking memory pressure with system.metrics and alert well before max_bytes so the circuit breaker trips on warning, not on failure.
Gotchas & edge cases
- A graceful shutdown flushes; a SIGKILL does not.
SYSTEM SHUTDOWNand a cleanservice stopdrain buffers before exiting, so a planned restart loses nothing. An OOM kill,kill -9, power loss, or a crashed container skips the drain and discards every resident row. Never assume restarts are graceful — size the exposure window for the crash case. DETACH TABLEandDROP TABLEdiscard resident rows too. Detaching or dropping a buffer does not flush it first; the in-memory window is gone the moment the table leaves the server’s metadata. AlwaysOPTIMIZE TABLEthe buffer immediately before any detach, drop, or schema change that recreates it.- Silent flush retries mask a stuck destination. If the destination hits
parts_to_throw_insertor runs out of disk, the buffer’s flush fails and retries in the background with no client-visible error — rows accumulate untilmax_bytesblocks inserts. Watch destination insert errors insystem.query_log(type = 'ExceptionWhileProcessing') alongside buffer memory, so a failing flush surfaces before the ceiling does. - Committing offsets before flush breaks the whole guarantee. The most common data-loss bug is a consumer that auto-commits on a timer independent of flush state. Disable auto-commit and commit manually only after the drain in Step 2, or the exposure window silently becomes unrecoverable — the source has moved on from rows the buffer never persisted.
Related
- Async Processing & Buffer Tables — the RAM-only buffer pattern whose volatility this page mitigates.
- Configuring Kafka Consumer Groups for ClickHouse — the offset-commit mechanics that gate durability.
- Deduplicating Inserts with Insert Deduplication — absorbing the duplicates that at-least-once replay produces.
- Tuning Buffer Table Flush Thresholds — shrinking max_time to bound the exposure window.
- Tracking Memory Pressure with system.metrics — alerting before the buffer reaches its ceiling.