Metrics and diagnosis¶
When quackjvm is slow, the first question is whether it is waiting or working. From the outside the two look the same. They have opposite fixes:
- A write queueing for its collection's write lock gets faster when you batch writes.
- A panel query slowed because DuckDB is short of CPU gets faster when each query asks for fewer threads.
Every operation that can queue records its wait apart from its work. Diagnosis reads an interval
of those numbers and says which cause it is, with what it saw and what to change.
Reading them¶
A DuckDBDatabase records its metrics from the start. Take two snapshots and subtract them to get
an interval:
MetricsSnapshot before = database.metrics().snapshot();
Thread.sleep(10_000);
MetricsSnapshot interval = database.metrics().snapshot().minus(before);
for (Diagnosis.Finding finding : Diagnosis.of(interval)) {
System.out.println(finding);
}
WRITE_LOCK: writes to orders are queueing for its write lock
seen: 88% of write time spent waiting for the lock; 5,563 writes, wait p50 2.49 ms, p99 3.54 ms;
each held it 0.35 ms on average
do: write fewer, larger batches - addAll(...) or a DuckDBBulkWriter take the lock once for many
objects. If writers never touch the same objects, serializeWrites(false) lets them run
together; if they do, it turns this wait into conflicts.
Nothing found means nothing is choking. Any single metric is also available directly:
interval.timer(QuackMetrics.WRITE_LOCK_WAIT, "orders").percentileMillis(99);
interval.statementsByTotalTime(); // where DuckDB's time went, by statement
interval.cpuUtilisation(); // 0..1, DuckDB's native threads included
What each cause looks like¶
The load test MetricsDiagnosisTest produces each of these on purpose. It checks that the cause
is named first, and that a light load produces no finding at all.
| cause | what shows it | what to change |
|---|---|---|
WRITE_LOCK |
Writes spend over a quarter of their time waiting for the collection's lock | Batch writes (addAll, DuckDBBulkWriter), or split the collection |
CPU |
Cores over 70% busy, in this process or across the whole machine, with more than 1.5 heavy (≥ 1 ms) statements running at once | SET threads to half the cores, cap concurrent heavy queries, materialize the hottest one |
CONFLICTS |
Statements failing with a conflict | Keep serializeWrites on where writers overlap |
MEMORY |
Temporary files in use, or memory at 90% of memory_limit |
Raise memory_limit, or pre-aggregate the large sorts and joins |
PREPARES |
Over 20% of prepares miss the statement cache | Bind values with ? instead of writing them into the SQL |
CONNECTION_CHURN |
Connections opened for over 10% of requests | Raise maxPooledConnections to the number of concurrent threads |
ERRORS |
Other failed statements | Check the application's logs |
Some causes produce symptoms that look like other causes. The rules account for four of them:
- A new connection starts with an empty statement cache. Pool churn therefore shows up as prepare misses, and those misses are attributed to the churn.
- DuckDB's JDBC driver closes a prepared statement when it fails. Each conflict therefore costs a re-prepare, and those misses aren't blamed on how the SQL was written.
- Another process can take the cores. DuckDB is short of CPU either way, so the rule uses whichever is busier, this process or the whole machine. It also says when the difference is other processes. Measured with two applications on one machine: this process 31%, the machine 100%.
- Sub-millisecond writes can keep the machine busy on one thread each. Lowering
threadswouldn't help them, so they don't count towardsCPU.
What is recorded¶
| name | kind | scope |
|---|---|---|
request.read, request.write |
timer, borrow to close of a request's connection | collection, or sql for database.sql/query/join |
write_lock.wait |
timer | collection |
statement |
timer; a query runs until its statement is closed, since streamed results are computed as they are read | the statement's shape: literals and ? lists collapsed |
statement.errors |
counter | conflict, constraint, memory, other |
prepare.cache_hits, prepare.cache_misses |
counter | |
connections.opened, connections.discarded |
counter | |
connections.in_use |
gauge | |
cpu.process_time_ns |
cumulative; cpuUtilisation() divides it by the interval |
|
cpu.machine |
gauge, 0 to 1: the whole machine, other processes included | |
duckdb.threads, duckdb.memory_bytes, duckdb.memory_limit_bytes, duckdb.temp_file_bytes |
gauge, read from DuckDB at each snapshot |
Timers are log-linear histograms (eight buckets per power of two), so a percentile read from one is within 6% of the true value. Subtracting two snapshots gives the percentiles of just the interval between them.
Profiles¶
Once a minute, each heavy statement has one real execution profiled by DuckDB. "Heavy" means it
averages 1 ms or more a call. You get what EXPLAIN ANALYZE would show, without running the query
again:
QueryProfile profile = database.metrics().profile(QuackMetrics.shapeOf(sql));
profile.getRowsScanned(); // after DuckDB skipped the blocks its filters ruled out
profile.getParallelism(); // CPU time over wall time: how many cores it kept busy
profile.getOperators(); // the plan, each step's time and the rows it produced
Three things measured while building it:
- DuckDB's own "latency" figure in a profile is wrong when the profiler is switched on for one
statement at a time. It measures from when the profiler was last switched, not from when the
statement began: a 2 ms statement sampled 300 ms after the last sample reported 300 ms. So the
time is quackjvm's own measurement of that call.
- The profile of a query whose results weren't read to the end is empty. So quackjvm's one-row
reads finish their results.
- The profile contains data values, including values bound to ?. They are removed.
Turn profiling off with profileStatements(false), or metrics.setProfiling(false).
What it costs¶
Recording a request or a statement costs a few atomic increments. Measured on single adds, primary key lookups and small SQL queries, with metrics on and off in alternating runs, the difference was inside run-to-run noise: 338 to 350 µs per add either way. So it is on by default. To turn it off:
Without a DuckDBDatabase¶
Code that uses the core module with its own connections can pass them through meter, which
times their statements the same way:
QuackMetrics metrics = new QuackMetrics().watch(rootConnection); // DuckDB memory and threads
try (Connection connection = metrics.meter(rootConnection.duplicate())) {
...
}
This path has no request or write-lock timings, because it has no pool and no locks. CPU, conflicts, memory and the statement timings all work.