Build a log flusher

Many callers, one background writer, and a disk nobody should have to wait for: the lock held only for a handoff, a batch closed by size or by time, and two buffers that swap.

Why must the flusher not hold the lock while it writes?

Because the write is the slow step, and a lock held across it turns every caller into a caller waiting on the disk. The point of a background writer is that log() returns at once, and it cannot if log() has to queue behind a disk write to take the lock. Under one recorded schedule on this page the two callers spent 26 turns refused when the write was inside the critical section and 0 when the flusher only swapped a pointer. The rule generalises: hold a lock for the shortest piece of work that keeps the invariant, and do anything slow (disk, network, compression, formatting) outside it.

Why flush on both a record count and a timer rather than one of them?

Because each alone fails in the case the other covers. With only a count, a system that goes quiet leaves the last few records sitting in memory for as long as the quiet lasts, which the build shows: the same schedule writes six records with both triggers and four with the count alone. With only a timer, a system under load writes tiny batches every interval and you lose the throughput the batching was for. Two triggers, whichever fires first, is the standard shape, and the two numbers are the knobs you tune.

What exactly does a crash lose?

Everything in memory that has not been written and fsynced, which is at most one batch plus whatever arrived since the last flush started. Two things people get wrong here: write() returning does not mean the bytes are on the disk, it means they are in the operating system's page cache, so durability needs flush() and then os.fsync(); and a batch that is being written when the process dies can land partly. If you need to know a record survived, acknowledge it only after its batch is fsynced, and accept that the caller now waits for the disk, which is the thing batching exists to avoid.

Does double buffering actually beat copying the list out?

It beats it by the cost of the copy, which is proportional to the batch size and is paid with the lock held. Swapping two references is constant time whatever the batch holds, so the critical section stops growing with the load. In Python the middle ground, rebinding self._buffer to a fresh list, is also constant time, so the honest claim for the two-buffer version is narrower: it reuses two lists for ever instead of allocating one per flush, and it makes the handoff visible in the code. Say both, and say which one you would ship.

What do you do when the buffer fills faster than the disk drains it?

Decide, out loud, which of three you want, because an unbounded buffer is a decision too and it is the worst one: it turns a slow disk into a process that runs out of memory. Block the caller once the buffer hits a cap, which applies backpressure and makes logging slow rather than fatal. Drop the record and count the drops, which keeps the application fast and loses data you must then be able to see in a metric. Or fail the call and let the caller decide. For application logs, dropping with a counter is usually right; for an audit log, blocking is.