rust sql 99 lines · 3 tabs

Batching Database Writes in Rust with a Size- and Interval-Triggered Flush Buffer

Shared by codesnips Jul 2026
3 tabs
use sqlx::PgPool;
use std::time::Duration;
use tokio::sync::mpsc;

#[derive(Debug, Clone)]
pub struct MetricPoint {
    pub name: String,
    pub value: f64,
    pub recorded_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Clone)]
pub struct BatchWriter {
    tx: mpsc::Sender<MetricPoint>,
}

impl BatchWriter {
    pub fn spawn(pool: PgPool, max_batch: usize, flush_interval: Duration) -> Self {
        let (tx, rx) = mpsc::channel(max_batch * 4);
        tokio::spawn(crate::writer_task::run(pool, rx, max_batch, flush_interval));
        BatchWriter { tx }
    }

    pub async fn submit(&self, point: MetricPoint) -> Result<(), &'static str> {
        // Awaits when the channel is full, applying backpressure to producers.
        self.tx.send(point).await.map_err(|_| "writer task stopped")
    }
}
3 files · rust, sql Explain with highlit

This snippet shows a common throughput optimization: instead of issuing one INSERT per event, incoming rows are accumulated in memory and flushed to the database in batches, triggered either by a size threshold or by a periodic timer. Writing rows one at a time wastes round-trips and transaction overhead; a multi-row INSERT amortizes that cost dramatically while keeping latency bounded by the flush interval.

In metrics.rs, the MetricPoint struct is the unit of work, and BatchWriter owns the plumbing. Producers never touch the database directly — they call submit, which pushes onto a bounded tokio::mpsc channel. The channel is bounded on purpose: if the writer falls behind, submit awaits capacity, giving the system natural backpressure instead of an unbounded memory blow-up. The BatchWriter::spawn constructor returns a cheap Clone-able handle while the actual draining logic runs on its own task.

The heart of the pattern lives in run, inside writer_task.rs. It uses tokio::select! to race two events: a new message arriving on the receiver, or the flush_interval tick firing. When buf.len() reaches max_batch, it flushes immediately so a burst never grows unbounded; otherwise the interval guarantees that even a single straggling row is persisted within a bounded delay. When the channel closes and recv returns None, the loop performs one final flush and exits cleanly, which matters for graceful shutdown so no buffered data is lost.

flush_batch builds a single parameterized multi-row statement with QueryBuilder::push_values, so all buffered points go in one round-trip. On error it logs and drops the batch rather than blocking the pipeline forever — a deliberate trade-off favoring liveness over strict durability. schema.sql shows the target table with a BRIN index, which suits append-heavy time-series inserts.

The key trade-offs: batching increases throughput but adds up to flush_interval of latency, and a crash can lose the in-memory buffer, so this fits telemetry and metrics rather than financial records. The bounded channel plus size-or-interval flush is the reusable core a developer reaches for whenever high-volume writes dominate.


Related snips

Share this code

Here's the card — post it anywhere.

Batching Database Writes in Rust with a Size- and Interval-Triggered Flush Buffer — share card
Link copied