Skip to content
About
Main content

Find the compaction interval

Measure your machineNeeds Python with DuckDB, PyArrow

Problem

Unmerged delta files make every scan slower by some cost c each; removes them at a cost bU (rewriting the table). With deltas arriving at w per hour and a scans per hour, the cost per hour is bU/τ for compaction plus a·c·w·τ/2 for reading through the average backlog, minimal at τ* = √(2·bU / (a·c·w)). The constants are properties of your table and workload, so they have to be measured.

Measure your own constants on a local Parquet table: the extra scan time per unmerged delta c (from scans with 0 to 80 delta files), and the cost to rewrite the table bU. With a delta rate of 60 per hour and 120 full scans per hour, compute τ*, then compute a day of the workload at τ*/4, τ* and 4τ* and record total cost (read overhead plus compaction time).

The question. What interval do the measured constants give, and how much does missing it by 4× cost?


Predict

Compacting four times more often than τ*: how does a day's total cost compare with the cost at τ*?

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.

    find-the-compaction-interval.py58 linesDownload
    View the code
    find-the-compaction-interval.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["duckdb", "numpy", "pyarrow"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Find the compaction interval.
    
    Measures two constants on a local Parquet table: c, the extra scan time per
    unmerged delta file, and bU, the time to rewrite the table (compaction).
    With a delta arrival rate w and a read rate a, minimises bU/τ + a·c·w·τ/2 at τ* = √(2·bU / (a·c·w))
    and then simulates a day of the workload compacting every τ*/4, τ* and 4τ*,
    adding up read overhead and compaction time.
    """
    import argparse, json, math, os, shutil, tempfile, time
    import duckdb, numpy as np, pyarrow as pa, pyarrow.parquet as pq
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=5_000_000, help="Rows in the table.")
    p.add_argument("--w", type=float, default=60, help="Delta files arriving per hour.")
    p.add_argument("--a", type=float, default=120, help="Full scans per hour.")
    p.add_argument("--hours", type=float, default=24, help="Simulated hours.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    rng = np.random.default_rng(0); d = tempfile.mkdtemp()
    pq.write_table(pa.table({"id": np.arange(args.rows), "amount": rng.integers(1, 10_000, args.rows)}), os.path.join(d, "base.parquet"))
    c = duckdb.connect(); c.execute("SET threads = 4")
    def scan_time(k):
        for x in [x for x in os.listdir(d) if x.startswith("delta-")]: os.remove(os.path.join(d, x))
        for i in range(k):
            ids = rng.choice(args.rows, 50, replace=False); pq.write_table(pa.table({"id": ids, "amount": np.zeros(50, dtype=np.int64)}), os.path.join(d, f"delta-{i:04d}.parquet"))
        src = f"read_parquet('{d}/delta-*.parquet')" if k else "(SELECT 0::BIGINT AS id, 0::BIGINT AS amount WHERE false)"
        q = f"SELECT sum(coalesce(dl.amount, b.amount)) FROM read_parquet('{d}/base.parquet') b LEFT JOIN {src} dl USING (id)"
        c.execute(q).fetchall(); ts = []
        for _ in range(3):
            t = time.perf_counter(); c.execute(q).fetchall(); ts.append(time.perf_counter() - t)
        return min(ts)
    ks = [0, 10, 40, 80]; times = [scan_time(k) for k in ks]
    cost_per_delta = float(np.polyfit(ks, times, 1)[0])                     # c: seconds of extra scan per unmerged delta
    t = time.perf_counter(); c.execute(f"COPY (SELECT * FROM read_parquet('{d}/base.parquet')) TO '{d}/rewritten.parquet' (FORMAT parquet)"); bU = time.perf_counter() - t
    shutil.rmtree(d, ignore_errors=True)
    tau_star = math.sqrt(2 * bU / (args.a * cost_per_delta * args.w))       # hours; cost/h = bU/τ + a·c·w·τ/2
    def day(tau):
        read = args.a * cost_per_delta * args.w * tau / 2 * args.hours            # average unmerged deltas = w·τ/2
        compact = bU * args.hours / tau
        return read, compact
    rows = []
    for label, tau in (("τ*/4", tau_star / 4), ("τ*", tau_star), ("4τ*", 4 * tau_star)):
        r, cpt = day(tau); rows.append({"interval": label, "tau_h": tau, "read_overhead_s": r, "compaction_s": cpt, "total_s": r + cpt})
    summary = {"c_ms": cost_per_delta * 1000, "bU_s": bU, "tau_star_h": tau_star, "w": args.w, "a": args.a}
    print(f"measured: c = {summary['c_ms']:.2f} ms of scan per unmerged delta file, bU = {bU:.2f} s to rewrite the table; τ* = {tau_star:.2f} h")
    for r in rows: print(f"compact every {r['interval']:<5} ({r['tau_h']:5.2f} h): read overhead {r['read_overhead_s']:7.1f} s + compaction {r['compaction_s']:7.1f} s = {r['total_s']:7.1f} s per day")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"intervals": rows, "scans": [{"deltas": k, "scan_s": tm} for k, tm in zip(ks, times)]}}, 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 find-the-compaction-interval.py
    uv run find-the-compaction-interval.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows5,000,000 Rows in the table.
--w60 Delta files arriving per hour.
--a120 Full scans per hour.
--hours24 Simulated hours.

Our run

A day of the workload

A day of the workload
IntervalHoursRead overhead (s)Compaction (s)Total (s)
τ*/40.1390.97215.616.5
τ*0.5573.893.897.78
4τ*2.2315.60.97216.5
Reference runApple M2 · 8 cores · 8 GB
Output · uv run find-the-compaction-interval.py
measured: c = 0.08 ms of scan per unmerged delta file, bU = 0.09 s to rewrite the table; τ* = 0.56 h
compact every τ*/4  ( 0.14 h): read overhead     1.0 s + compaction    15.6 s =    16.5 s per day
compact every τ*    ( 0.56 h): read overhead     3.9 s + compaction     3.9 s =     7.8 s per day
compact every 4τ*   ( 2.23 h): read overhead    15.6 s + compaction     1.0 s =    16.5 s per day
macOS 26.6.2 · Python 3.13.3 · duckdb 2.0.0 · numpy 2.5.4 · pyarrow 26.0.0 · run 2026-10-25

What we found

Each unmerged delta file added 0.081 ms to a scan and a rewrite of the table took 90.2 ms, so with 60 deltas and 120 scans an hour τ* = 0.557 hours. A day at τ* cost 7.78 s, half read overhead and half compaction; at τ*/4 and 4τ* it cost 16.5 s and 16.5 s. The day is computed from the measured constants with the cost model, not run for 24 hours, so its symmetry around τ* is the model's.


Read it

The interval follows from two measurements and two rates, and it moves with the square root of each: four times the delta rate halves it. Re-measure c after format or engine upgrades, since a cheaper merge at read time lengthens the right interval.