Skip to content

Statistics

Statistics

Every statistics call fills a caller-owned struct by value. Nothing is allocated and nothing is freed — declare the struct on the stack, pass its address, and read the fields.

tidesdb_db_stats_t stats;
if (tidesdb_get_db_stats(db, &stats) == TDB_SUCCESS)
printf("%d sstables\n", stats.total_sstable_count);
/* nothing to free */

The values are samples taken without stopping the engine, so counters can be slightly inconsistent with each other — a flush may land between two fields being read. They are for monitoring and capacity planning, not for making transactional decisions.

tidesdb_get_cf_stats

Collect one column family’s statistics.

Synopsis

int tidesdb_get_cf_stats(tidesdb_column_family_t *cf, tidesdb_cf_stats_t *stats);

Description

Fills stats with the shape and history of one family. Note the signature takes no database handle — the family handle is enough.

Shape of the tree. num_levels is how many levels hold data, and the four level_* arrays are indexed by level up to TDB_MAX_LEVELS (8). read_amp estimates how many sstables a point read must consult; it is the number to watch when reads slow down.

FieldMeaning
num_levelsLevels currently holding data
level_sizes[]Bytes per level
level_num_sstables[]Sstable count per level
level_key_counts[]Keys per level
level_tombstone_counts[]Tombstones per level
total_keys / total_data_sizeTotals across levels. total_data_size is the family’s key logs and nothing else, so it equals the sum of level_sizes. Values below value_separation_threshold are already inside those bytes; what spilled lives in the shared value log, reported at database level as vlog_file_size
avg_key_size / avg_value_sizeAverages in bytes
read_ampEstimated sstables consulted per point read

The btree. btree_total_nodes, btree_max_height, and btree_avg_height describe the key logs. Height rising over time on a family whose key count is stable usually means the node size is too small for the keys being stored.

Tombstones. total_tombstones and tombstone_ratio are what the tombstone_density_trigger acts on. A ratio that stays high means deletes are outrunning compaction. max_sst_density and max_sst_density_level locate the worst single sstable.

Write volume. wal_bytes_written, flush_bytes_written, compaction_bytes_written, compaction_bytes_read, and user_bytes_written are cumulative since open.

A family’s wal_bytes_written is an attribution, not a measurement. One log serves the whole database, so this is the encoded size of this family’s own entries; the batch header and the block framing belong to no single family, and these do not sum to what the log wrote. The database-level field of the same name is measured at the append and is the one to use.

None of them count a separated value, because the log does not carry one — the record holds an id of a few bytes and the bytes themselves are in vlog_bytes_written. A family whose values are all above the threshold therefore reports a small wal_bytes_written against a large user_bytes_written, and that is the arrangement working rather than an accounting gap.

In memory. unflushed_key_count is the distinct keys belonging to this family that are still only in the memtable. It is the family’s share of what a flush would write and what a crash would have to recover. It is reported as an unsigned figure and floored at zero: the underlying count can dip below it for an instant when a flush’s decrement is observed before the matching apply increment, and that dip is an artefact of reading two independent updates rather than a real figure.

Filter memory. filter_resident_bytes is what this family’s partition range filters hold outside the block cache. Each open sstable keeps one routing directory — every partition’s offset and its whole first key — and that directory is ordinary heap memory, not charged against block_cache_size. The filter bit arrays are not counted: those are fetched one partition at a time per probe and live in the cache, where the configured budget already bounds them.

Two things follow from how it is built. A directory is created on a table’s first probe, not at open, so this reads zero for a family nothing has read from yet and climbs as tables are touched. And its size tracks the number of partitions and the length of the keys, not the volume of data — a family with long keys and many small tables can hold more here than one storing far more data under short keys. Summing it across families is what answers “the process is larger than the cache budget and I want to know why”.

Configuration. config is a copy of the family’s current configuration, including any runtime changes applied since it was created. It is filled in here so a caller reading the numbers has the settings that produced them in the same struct, rather than having to correlate two calls that could disagree.

Errors

CodeCause
TDB_ERR_INVALID_ARGScf or stats is NULL
TDB_ERR_LOCKEDDescriptor pressure kept a level from being read. Transient — retry rather than reporting the family as empty
TDB_ERR_MEMORYAllocation failed while collecting the level snapshot

tidesdb_cf_estimate_cardinality

Estimate a family’s distinct key count.

Synopsis

int tidesdb_cf_estimate_cardinality(tidesdb_column_family_t *cf, uint64_t *out_estimate);

Description

Estimates how many distinct keys the family holds. This is not the same as total_keys from tidesdb_get_cf_stats, which counts entries across levels and therefore counts a key once per level that holds a version of it.

It is an estimate. Use it for planning, not for anything that must be exact — there is no exact distinct count short of a full scan.

Errors

CodeCause
TDB_ERR_INVALID_ARGScf or out_estimate is NULL
TDB_ERR_LOCKEDDescriptor pressure kept a level from being read. Transient — retry
TDB_ERR_MEMORYAllocation failed while collecting the level snapshot

tidesdb_get_db_stats

Collect database-level statistics.

Synopsis

int tidesdb_get_db_stats(tidesdb_t *db, tidesdb_db_stats_t *stats);

Description

Fills stats with figures spanning every family plus the shared write path.

Inventory. num_column_families, total_sstable_count, total_data_size_bytes, num_open_sstables (against the max_open_sstables budget), and next_cf_index.

Write path pressure. immutable_memtable_count is sealed memtables waiting to be flushed — the queue that backpressure watches. compaction_pending_count, memtable_bytes, and is_flushing complete the picture. A persistently non-zero immutable_memtable_count means flush is not keeping up with ingest.

memtable_bytes counts every version resident in the write buffer, not one per distinct key, and counts each by what it occupies rather than by the value it carries. A key overwritten repeatedly adds a version each time rather than replacing one, because a reader at an older snapshot may still need the version being superseded. A delete adds one too — a tombstone carries no value but is still a resident allocation — as does a put of an empty value, which is what a secondary index entry usually is. It is also the number the rotation threshold reads, so it is the one to watch if a memtable seems not to be sealing.

Backpressure. These four are the ones to alert on:

FieldMeaning
writes_throttledWriters made to dwell before being admitted
writes_blockedWriters made to wait for the queue to drain
write_stall_usTotal microseconds writers spent held in admission
write_stall_ceiling_hitsWriters admitted only because the wait ceiling expired

All zero means ingest is comfortably within what flush can absorb. write_stall_ceiling_hits climbing is the serious one: it means flush is not draining and writes were let through anyway to avoid turning a slow database into a stuck one.

Two separate pressures feed these four: the immutable memtable queue, and the write-ahead log’s staging ring. That is worth knowing when reading them, because the two look different — a rising write_stall_us with a flat writes_blocked is the ring band doing its job, applying many small dwells rather than one long stop.

MVCC. global_seq is the current sequence; min_snapshot_seq is the oldest snapshot any live transaction holds, and it is what gates tombstone reclamation — a long-running transaction holds it back and keeps garbage alive. active_txn_count and txn_memory_bytes cover the live transactions themselves.

Value log. vlog_file_size, vlog_value_count and vlog_used_bytes describe the contents; vlog_segment_count is how many files they are spread across, one of which is taking appends.

vlog_bytes_written is cumulative rather than a description of the contents, counting every byte ever appended including the rewrites reclamation performs. It is the term write amplification needs from the log, and it is the only value-log field that answers what the device was asked to write rather than what is held now.

vlog_stored_bytes is the on-disk length of the indexed values. It is only a meaningful ratio against vlog_used_bytes when the whole store was written under one pipeline — use tidesdb_get_vlog_encoding_stats instead, which keeps the chains apart.

vlog_live_bytes is what the installed sstables still reference, summed from what each records about the segments its separated values landed in. It is the figure space amplification is against. vlog_used_bytes is not: the index names every value whose segment has not been dropped, reachable or not, so it converges on the file size and reports a store full of garbage as entirely live.

vlog_dead_bytes is the gap between file size and used bytes — space held by values no live key references, and what a reclaim can recover.

The rest describe reclamation itself, and reset when the handle reopens because they measure what this process has done rather than what is on disk. vlog_reclaim_calls counts every reclaim attempted; vlog_reclaim_passes counts only those that actually drained a segment, and vlog_segments_retired counts the files freed. Read calls against passes: a reclaim that is never called and one that is called and finds nothing to do are different faults with different fixes, and the passes count alone cannot tell them apart. vlog_segments_drainable counts the sealed segments holding so little live data that emptying them is worthwhile — those are emptied by the next compaction that carries one of their values, not by the store itself.

Read vlog_dead_bytes against vlog_segments_drainable. The first is the work outstanding and the second is how much of it is currently actionable. Dead bytes climbing while the drainable count stays high means compaction is not reaching those segments; dead bytes climbing with nothing drainable means the garbage is spread thin across segments that are still mostly live.

Encoding stats {#encoding-stats}

int tidesdb_get_klog_encoding_stats(tidesdb_t *db, tidesdb_encoding_stats_t *out, size_t max,
size_t *out_count);
int tidesdb_get_vlog_encoding_stats(tidesdb_t *db, tidesdb_encoding_stats_t *out, size_t max,
size_t *out_count);

out is caller-allocated with capacity max, and out_count receives how many entries were written; sizing out beyond TDB_MAX_ENCODING_CHAINS gains nothing, since that is the most that can exist. Both return TDB_SUCCESS, or TDB_ERR_INVALID_ARGS on a NULL argument.

tidesdb_get_klog_encoding_stats and tidesdb_get_vlog_encoding_stats report one row per encoding chain, up to TDB_MAX_ENCODING_CHAINS. Each row names the chain in ids (the codec ids in the order applied, with id_count of them, and empty when the data was stored verbatim) and then reports logical_bytes, stored_bytes, and item_count — values for the value log, sstables for the key logs. logical_bytes / stored_bytes is that codec’s realised ratio on the data it actually wrote.

They are reported per chain rather than per column family for a reason. A family can change its codec, and compaction rewrites data under whichever pipeline is merging it, so a single figure for a family averages across settings that no longer apply. A key log is attributed by the pipeline recorded in its own footer; a value is attributed by the chain recorded in its own block header. A family mid-migration shows two rows, which is the truth, rather than one number that is not.

Durability. wal_generation is the current write-ahead log generation, incremented on each rotation. flush_count and compaction_count are cumulative, alongside the same byte counters as the per-family stats.

Errors

TDB_ERR_INVALID_ARGS if db or stats is NULL.

tidesdb_get_stall_stats

Report where writers have been made to wait.

Synopsis

int tidesdb_get_stall_stats(tidesdb_t *db, tidesdb_stall_stats_t *stats);
const char *tidesdb_stall_reason_name(tidesdb_stall_reason_t reason);

Description

A write latency tail is one of the hardest things to explain from the outside: the p50 is fine, a handful of commits take a thousand times longer, and nothing in the ordinary statistics says which part of the engine held them. This answers that directly. Every place a caller’s own thread can be made to wait reports a count, a total, and the longest single wait — and it is the last one that a tail is made of, since a total cannot tell many short waits from one long one.

ReasonConstantThe caller was
wal_appendTDB_STALL_WAL_APPENDwaiting on the write-ahead log — for staging-ring space, or under a syncing mode for its record to reach the file
rotate_lockTDB_STALL_ROTATE_LOCKtaking the rotation lock, which a committer declines rather than waits for when another thread holds it
rotate_workTDB_STALL_ROTATE_WORKperforming the rotation itself, which one committer pays on every other’s behalf
admissionTDB_STALL_ADMISSIONheld by write admission because the unflushed backlog was too deep
manifest_commitTDB_STALL_MANIFEST_COMMITinside a manifest commit, which every flush install, every compaction install and every DDL serialises through

The short name is what tidesdb_stall_reason_name returns; the constant is how you index reasons[] for one particular reason. TDB_STALL_COUNT is the number of reasons, not itself a reason.

Read it by comparing each reason’s max_us against the tail you measured. If one of them matches the tail, that is where the time went. If wal_append dominates, the log is the constraint and the next question is whether the device can keep up — compare the bytes written against what the device can sustain, because a saturated disk and a stalled engine look identical from the application.

manifest_commit is the one to read against the durability mode. Under TDB_SYNC_NONE a commit is bookkeeping only and costs a couple of microseconds, so a non-trivial total there means something else. Under a syncing mode it carries an fsync, and periodically a rollover that rewrites the whole catalogue — both under an exclusive lock every installer needs — so a total that grows with the flush rate is that serialisation rather than a fault.

A reason reporting a non-zero total against a zero longest is an accounting fault, not a reading: every path that adds to a total must also offer its wait to the maximum.

The totals are cumulative since the database opened and never reset, so sample twice and subtract to attribute a particular window.

Return Value

TDB_SUCCESS, or TDB_ERR_INVALID_ARGS if db or stats is NULL. tidesdb_stall_reason_name returns a stable short name, or "unknown" outside the enum.

Thread Safety

Safe from any thread at any time. The counters are relaxed atomics on the write path — they are reported, never decided on — so reading them neither blocks writers nor perturbs what it measures.

The longest is the one exception, and it is there for the comparison above. It is published and read with enough ordering that total_us is always at least max_us; without it the two could be seen in either order, and a reason whose total is still small would report a longest wait larger than the sum containing it. The same holds for the per-class write statistics below.

Examples

tidesdb_stall_stats_t stalls;
if (tidesdb_get_stall_stats(db, &stalls) == TDB_SUCCESS)
for (int i = 0; i < TDB_STALL_COUNT; i++)
printf("%-12s count=%llu total_ms=%.1f max_ms=%.1f\n",
tidesdb_stall_reason_name((tidesdb_stall_reason_t)i),
(unsigned long long)stalls.reasons[i].count,
(double)stalls.reasons[i].total_us / 1000.0,
(double)stalls.reasons[i].max_us / 1000.0);

See Also

tidesdb_get_db_stats

tidesdb_get_io_stats

Report what each class of file asked of the device.

Synopsis

int tidesdb_get_io_stats(tidesdb_t *db, tidesdb_io_stats_t *stats);
const char *tidesdb_io_class_name(tidesdb_io_class_t cls);

Description

This is the other half of tidesdb_get_stall_stats. That one says writers waited on the log; this one says whether the device was the reason, and how much traffic the rest of the engine was putting beside it.

ClassConstantWritten by
sstableTDB_IO_SSTABLEflush and compaction, writing key logs
walTDB_IO_WALthe write-ahead log’s own single flush thread
vlogTDB_IO_VLOGvalue log segments, written by a commit that separates a value and by the reclaim that copies live values forward

As with the stall reasons, the short name is what tidesdb_io_class_name returns and the constant is how you index classes[]; TDB_IO_COUNT is the number of classes, not itself a class.

Each class reports ops writes issued, bytes written, total_us summed inside those writes, and max_us for the slowest single one.

Read bytes over total_us as the throughput that class achieved, and bytes over ops as the average write size — a class issuing many small writes pays per-call overhead that the throughput figure alone hides. The comparison that matters is between the two classes: if sstable is moving several times the bytes the wal is, the log’s writes are queued behind that traffic, and the lever is write amplification rather than anything in the log.

Only handles opened through the engine’s descriptor manager are counted, which is every key log and every write-ahead log. The value log and the manifest are not, so this measures the two classes that compete for the device under load rather than every byte the database writes.

Totals are cumulative since open and never reset, so sample twice and subtract for a window.

Return Value

TDB_SUCCESS, or TDB_ERR_INVALID_ARGS if db or stats is NULL. tidesdb_io_class_name returns a stable short name, or "unknown" outside the enum.

Thread Safety

Safe from any thread at any time; the counters are relaxed atomics on the write path, apart from max_us, which carries enough ordering that total_us is always at least as large as it.

See Also

tidesdb_get_stall_stats, tidesdb_get_db_stats

tidesdb_get_cache_stats

Collect block cache statistics.

Synopsis

int tidesdb_get_cache_stats(tidesdb_t *db, tidesdb_cache_stats_t *stats);

Description

Fills stats for the database-wide block cache, which every column family shares.

FieldMeaning
enabled1 when a cache is configured
total_entries / total_bytesCurrent residency
hits / missesCumulative since open
hit_ratehits / (hits + misses), 0 when neither has happened
num_partitionsShards, derived from the configured size and the CPU count

hit_rate is the number that matters. A low rate on a read-heavy workload usually means block_cache_size is too small for the working set. Because it is cumulative since open, it is slow to reflect a recent change — compare deltas between two samples rather than the absolute value.

Errors

TDB_ERR_INVALID_ARGS if db or stats is NULL.

tidesdb_range_stats

Describe a key range for a query planner.

Synopsis

typedef struct
{
uint64_t sstables_overlapping;
uint64_t estimated_keys;
int keys_exact;
} tidesdb_range_stats_t;
int tidesdb_range_stats(tidesdb_t *db, tidesdb_column_family_t *cf, const uint8_t *key_a,
size_t key_a_size, const uint8_t *key_b, size_t key_b_size,
tidesdb_range_stats_t *out);

Description

Answers the two questions a planner asks about [key_a, key_b) — what a scan of it would cost, and how many rows it would return. Both come from one layout snapshot, so they describe the same instant rather than two moments the caller cannot distinguish.

sstables_overlapping is the number of sorted runs a scan would merge. That is the shape of the scan’s cost, and it is what decides between a range scan and a series of point lookups.

estimated_keys is the live key count. It is memtable-aware: a range whose data has not been flushed yet reports a real cardinality rather than zero, and a key that was flushed and then rewritten counts once rather than twice. Tombstoned keys are excluded, so a heavily deleted range reports what a reader would actually see.

keys_exact is what makes the count usable. A range small enough to walk is counted, and this flag is set. A wider one is estimated from sstable metadata without walking, and the flag is clear. A planner can commit to an exact figure and hedge on an estimated one — which it cannot do if both arrive as the same opaque number.

The gating is what keeps the call cheap at plan time. The metadata pass runs first and decides whether walking is worth it, so a wide range costs no more than the overlap count alone, and the walk only happens when it is provably short.

The estimate takes each overlapping sstable’s keys in proportion to the share of its own key span the range covers, interpolating both against the bounds the file records. Counting an overlapping file whole would be badly wrong in the shape that matters most: a file flushed from a memtable that held interleaved writes spans the entire key space, so every narrow range meets it, and every narrow range would be told it holds the entire store. That error compounds — an inflated estimate also pushes the range past the threshold under which it would have been counted exactly, so the caller loses the precise answer as well as getting a wrong approximate one.

Interpolation assumes keys are spread evenly within a file, which is rough for skewed key distributions. It is calibrated for being in the right order of magnitude rather than being exact, and the narrow ranges where precision matters most are the ones that fall under the threshold and get counted.

Errors

TDB_ERR_INVALID_ARGS if db, cf, either key, or out is NULL. Note that unlike the range functions elsewhere in the API, neither key may be NULL — there is no unbounded form.

TDB_ERR_LOCKED if the level layout changed while it was being read; the call is safe to retry.

TDB_ERR_MEMORY if the working set of sstable entries could not be allocated.