Group commit: break the 1/latency ceiling
Measure your machineNeeds Python
Problem
If every commit waits for its own flush, the log can commit at most 1 / flush_time transactions per second, however many clients are waiting. Group commit breaks that ceiling: while one flush is in progress, new commits queue; when it finishes, one flusher writes everything that queued and flushes once for all of them. Throughput then grows with the number of waiting clients, and each commit's latency stays close to one or two flush times, because no commit waits for more than the flush in progress and its own.
Write a tiny log server with threads. Version A: each thread does write + fsync under a lock. Version B: threads put records in a queue, one flusher thread takes everything in the queue, writes it, does one fsync, and wakes the waiting threads. Run 1, 8, 64 and 256 client threads. Plot commits per second against clients.
On macOS the script flushes with F_FULLFSYNC, the call that reaches stable media (see the previous lab).
The question. Does syncing per commit stay flat at 1 / fsync_time while group commit scales with clients, and what happens to latency in each?
Predict
Expect version A to stay flat at about 1 / fsync_time commits per second, and version B to rise with the number of clients, while its latency stays near 1–2 × the fsync time.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
group-commit-break-the-1-latency.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Group commit: break the 1 / fsync-latency ceiling. A tiny log server with client threads. Version A: each commit does write + sync under a lock. Version B: a flusher thread takes everything queued, writes it, syncs once, and wakes the waiters. Commits per second and commit latency for 1 to 256 clients. Uses F_FULLFSYNC on macOS (a real flush) and fsync elsewhere. """ import argparse, json, os, platform, queue, statistics, tempfile, threading, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--clients", type=lambda s: [int(v) for v in s.split(",")], default=[1, 8, 64, 256], help="Client threads.") p.add_argument("--seconds", type=float, default=5.0, help="Seconds per measurement.") p.add_argument("--path", default=os.path.join(tempfile.gettempdir(), "group-commit.log"), help="Log file.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() import atexit, shutil atexit.register(lambda: shutil.rmtree(args.path, ignore_errors=True) if os.path.isdir(args.path) else os.path.exists(args.path) and os.remove(args.path)) # leave nothing behind FULL = platform.system() == "Darwin" def sync(fd): if FULL: import fcntl; fcntl.fcntl(fd, fcntl.F_FULLFSYNC) else: os.fsync(fd) def run(version, clients): fd = os.open(args.path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) lock, stop, lat, done = threading.Lock(), threading.Event(), [], [0] q = queue.Queue() def flusher(): while not stop.is_set() or not q.empty(): try: batch = [q.get(timeout=0.05)] except queue.Empty: continue while not q.empty(): batch.append(q.get_nowait()) os.write(fd, b"".join(r for r, _ in batch)); sync(fd) for _, ev in batch: ev.set() def client(): rec = b"x" * 200 while not stop.is_set(): t = time.perf_counter() if version == "A": with lock: os.write(fd, rec); sync(fd) else: ev = threading.Event(); q.put((rec, ev)); ev.wait() lat.append(time.perf_counter() - t); done[0] += 1 th = [threading.Thread(target=client) for _ in range(clients)] if version == "B": th.append(threading.Thread(target=flusher)) for t in th: t.start() time.sleep(args.seconds); stop.set() for t in th: t.join() os.close(fd) return {"version": "A: sync per commit" if version == "A" else "B: group commit", "clients": clients, "commits_per_s": done[0] / args.seconds, "median_ms": statistics.median(lat) * 1000} rows = [] for v in ("A", "B"): for c in args.clients: r = run(v, c); rows.append(r) print(f"{r['version']:<20} {c:>4} clients: {r['commits_per_s']:9,.0f} commits/s median latency {r['median_ms']:6.2f} ms") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"runs": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run group-commit-break-the-1-latency.py uv run group-commit-break-the-1-latency.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --clients1, 8, 64, 256 | Client threads. |
| --seconds5 | Seconds per measurement. |
| --pathos.path.join(tempfile.gettempdir(), 'group-commit.log') | Log file. |
Our run
Data table
| Client threads | series | Commits/s |
|---|---|---|
| 1 | A: sync per commit | 359 |
| 8 | A: sync per commit | 378 |
| 64 | A: sync per commit | 393 |
| 256 | A: sync per commit | 430 |
| 1 | B: group commit | 375 |
| 8 | B: group commit | 1,550 |
| 64 | B: group commit | 9,980 |
| 256 | B: group commit | 30.6K |
Data table
| Client threads | series | Median latency (ms) |
|---|---|---|
| 1 | A: sync per commit | 2.99 |
| 8 | A: sync per commit | 23 |
| 64 | A: sync per commit | 167 |
| 256 | A: sync per commit | 670 |
| 1 | B: group commit | 2.99 |
| 8 | B: group commit | 5.84 |
| 64 | B: group commit | 6.33 |
| 256 | B: group commit | 8.25 |
A: sync per commit 1 clients: 359 commits/s median latency 2.99 ms
A: sync per commit 8 clients: 378 commits/s median latency 23.02 ms
A: sync per commit 64 clients: 393 commits/s median latency 167.02 ms
A: sync per commit 256 clients: 430 commits/s median latency 670.32 ms
B: group commit 1 clients: 375 commits/s median latency 2.99 ms
B: group commit 8 clients: 1,550 commits/s median latency 5.84 ms
B: group commit 64 clients: 9,982 commits/s median latency 6.33 ms
B: group commit 256 clients: 30,602 commits/s median latency 8.25 msWhat we found
Syncing per commit stayed flat: 359 commits per second with one client and 430 with 256, while the median latency grew with the queue, to 670 ms. Group commit rose with the clients, from 375 to 30.6K commits per second, and its median latency stayed between 2.99 and 8.25 ms, one to three flush times, as the mechanism predicts.
Read it
The flush is a fixed cost per batch, not per commit, so amortising it is the only way to get durable throughput above 1 / flush_time. The latency result is the less obvious half: without group commit, the clients still wait, but in the lock queue, so latency grows linearly with load while throughput does not. Group commit converts that waiting into batch size.
Every serious log does this (PostgreSQL's WAL writer, Kafka's batched appends, any write-ahead log). The knob that remains is how long to wait for a batch to fill; with enough concurrent clients the answer is "not at all", because the flush in progress is the wait.