From 5119d3da2a82be9ad0e6fd256e4b6f39dea95480 Mon Sep 17 00:00:00 2001 From: Anthony Alaribe Date: Tue, 9 Jun 2026 17:34:56 +0200 Subject: [PATCH 1/3] perf: speed up pgwire INSERT path and flush throughput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five changes targeting the production symptom of TF being unable to keep up with consumer ingest rate. Validated with bench/concurrent_load.py and bench/latency_probe.py against a local MinIO. 1. buffered_write_layer.rs — insert path no longer awaits flush_all_now under memory pressure. The previous behaviour stalled pgwire/gRPC threads on S3 commits and held the global flush_lock so one slow tenant froze ingest for everyone. The safety net is the existing hard-limit reject in try_reserve_memory + pressure_notify waking the background flush task. 2. database.rs — insert_records_batch now returns the URIs of files newly added by the commit, derived from the post-write snapshot under the same write lock. Callers (main.rs, bootstrap.rs) drop their pre/ post list_file_uris calls. Eliminates two redundant update_state log scans per flushed bucket — at prod scale (~2,700 add_files) that's the ~200-300ms per-bucket tax that was dominating flush latency. 3. buffered_write_layer.rs — flush_completed_buckets coalesces per (project_id, table_name): one Delta commit per (project, table) per cycle instead of one per bucket. Each commit pays a fixed log-scan + JSON + S3 RTT, so collapsing N micro-commits into one turns N×O(commit) into 1×O(commit). Also eliminates read-write contention measured as p95 dropping from 122-151ms to 70-85ms on the 8-writer + 4-reader bench. 4. buffered_write_layer.rs — tantivy index build spawned off the flush critical path. The Delta commit no longer waits on tar.zst + S3 upload; F4's coalescing naturally bounds the per-cycle tantivy queue depth to num_tables. 5. plan_cache.rs — new handle_extended_query for DML that substitutes params, folds CAST(Literal, T) into Literal_T, then executes. The insert_coerce pass wraps every $N in CAST so pgwire infers placeholder types; after substitution those CAST(literal, T) exprs stayed in every ValuesExec cell and were re-evaluated per (row, col) — at 88 cols x 30+ rows this was ~9-10ms/row of pure pipeline overhead, measured. Folding them up front drops single-conn throughput from 100 r/s to 161 r/s (+61%) and 16-writer throughput from ~440 r/s to 607 r/s (+38%). --- bench/latency_probe.py | 48 +++++++++ bench/run-tf-bench.sh | 42 ++++++++ src/bootstrap.rs | 8 +- src/buffered_write_layer.rs | 194 +++++++++++++++++++++++++++++------- src/database.rs | 30 +++++- src/grpc_handlers.rs | 5 +- src/main.rs | 17 ++-- src/plan_cache.rs | 102 ++++++++++++++++++- 8 files changed, 385 insertions(+), 61 deletions(-) create mode 100644 bench/latency_probe.py create mode 100755 bench/run-tf-bench.sh diff --git a/bench/latency_probe.py b/bench/latency_probe.py new file mode 100644 index 00000000..fed67544 --- /dev/null +++ b/bench/latency_probe.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Measure per-INSERT latency at the server with the same row shape as +concurrent_load.py — but synchronously, one batch at a time, to pin +TF's per-query cost vs client-side overhead.""" +import gzip, json, os, sys, time, uuid +from datetime import datetime, timezone, timedelta +from pathlib import Path +import psycopg + +ROOT = Path(__file__).resolve().parent +DUMP = ROOT / "data" / "sample.jsonl.gz" +URL = "host=127.0.0.1 port=12345 user=postgres password=postgres dbname=postgres" + +# Same columns as concurrent_load.py +sys.path.insert(0, str(ROOT)) +from concurrent_load import COLUMNS, JSON_COLS, LIST_COLS, _to_pg, load_rows, shift_for_project + +def main(): + rows = load_rows(2000) + shift = datetime.now(timezone.utc) - max(datetime.fromisoformat(r["timestamp"]) for r in rows) + pid = f"latprobe-{uuid.uuid4().hex[:6]}" + rows = shift_for_project(rows, pid, shift) + + placeholders = "(" + ", ".join(["%s"] * len(COLUMNS)) + ")" + base = f"INSERT INTO otel_logs_and_spans ({', '.join(COLUMNS)}) VALUES " + + for batch_size in (1, 10, 30, 100, 500, 1000): + with psycopg.connect(URL, autocommit=True) as c, c.cursor() as cur: + times = [] + for i in range(8): + batch = rows[i * batch_size : (i + 1) * batch_size] + if len(batch) < batch_size: break + flat = [] + for r in batch: + for col in COLUMNS: + flat.append(_to_pg(col, r.get(col))) + t = time.perf_counter() + cur.execute(base + ", ".join([placeholders] * len(batch)), flat) + times.append((time.perf_counter() - t) * 1000) + if not times: continue + warm = times[2:] # drop first 2 cold runs + avg = sum(warm) / len(warm) if warm else times[-1] + per_row = avg / batch_size + r_s = 1000 / per_row if per_row > 0 else 0 + print(f"batch={batch_size:>4} warm_avg={avg:7.1f}ms per_row={per_row:6.3f}ms → {r_s:7.0f} r/s (1 conn)") + +if __name__ == "__main__": + main() diff --git a/bench/run-tf-bench.sh b/bench/run-tf-bench.sh new file mode 100755 index 00000000..96f65a5f --- /dev/null +++ b/bench/run-tf-bench.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Restart TF for a clean benchmark iteration. +# Usage: ./bench/run-tf-bench.sh