Skip to content
About
Main content

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. 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. then grows with the number of waiting clients, and each commit's 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

Syncing on every commit, what happens to commits per second as clients go from 1 to 256?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    group-commit-break-the-1-latency.py69 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--clients1, 8, 64, 256 Client threads.
--seconds5 Seconds per measurement.
--pathos.path.join(tempfile.gettempdir(), 'group-commit.log') Log file.

Our run

Commits per second against clientsApple M2 · 8 cores · 8 GB
A: sync per commitB: group commit
Data table
Commits per second against clients
Client threadsseriesCommits/s
1A: sync per commit359
8A: sync per commit378
64A: sync per commit393
256A: sync per commit430
1B: group commit375
8B: group commit1,550
64B: group commit9,980
256B: group commit30.6K
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20
Median commit latency against clientsApple M2 · 8 cores · 8 GB
A: sync per commitB: group commit
Data table
Median commit latency against clients
Client threadsseriesMedian latency (ms)
1A: sync per commit2.99
8A: sync per commit23
64A: sync per commit167
256A: sync per commit670
1B: group commit2.99
8B: group commit5.84
64B: group commit6.33
256B: group commit8.25
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20
Output · uv run group-commit-break-the-1-latency.py
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 ms

What 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 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.