Find the compaction interval
Measure your machineNeeds Python with DuckDB, PyArrow
Problem
Unmerged delta files make every scan slower by some cost c each; compaction 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
Expect the minimum near τ* and a shallow curve around it.
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
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)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
| Knob · default | What 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
| Interval | Hours | Read overhead (s) | Compaction (s) | Total (s) |
|---|---|---|---|---|
| τ*/4 | 0.139 | 0.972 | 15.6 | 16.5 |
| τ* | 0.557 | 3.89 | 3.89 | 7.78 |
| 4τ* | 2.23 | 15.6 | 0.972 | 16.5 |
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 dayWhat 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.