Write-Path Optimizations
Write-Path Optimizations
Single-delete
A DELETE writes a tombstone into every column family the row touches, the primary row CF plus one
CF per secondary, full-text, or spatial index. A regular tombstone must be carried through every
compaction until it reaches the largest active level, because a lower level could still hold an older
put of the same key that the tombstone masks. Insert-then-delete workloads pile these at the low end
of the key space where DELETE range scans start, and scan cost climbs with the backlog until
compaction catches up.
The library’s single-delete primitive lets compaction drop a put and its matching tombstone together at whatever level the merge writes, instead of carrying the tombstone down to the largest level. It drops the pair only when the put lies directly beneath the single-delete, no reader can still see that put, and no table outside the merge holds the key, and a single-delete it keeps is written back still a single-delete so a later merge can drop it. Its contract is at most one put between single-deletes on the same key. For reads a single-delete behaves like a regular tombstone.
The engine applies this in two ways.
Secondary-index single-delete, automatic
Every secondary index entry, (col_values, pk) for a regular index, (term, pk) for FULLTEXT,
(hilbert, pk) for SPATIAL, is written exactly once per row lifetime and deleted exactly once,
across INSERT, UPDATE, DELETE, REPLACE INTO, and INSERT ... ON DUPLICATE KEY UPDATE. Two
UPDATE paths write an entry again in place, one that refreshes the expiry of a row in a table with a
TTL and one that rewrites a FULLTEXT term whose frequency or document length changed. A later
single-delete of such an entry still leaves it gone, because the library keeps a single-delete
while any SSTable outside the merge holds the key, and a merge that drops it drops every older
version of the key with it. The engine therefore uses single-delete for every secondary-index delete with
no configuration, which covers three of the four tombstones per deleted row on a table with three
secondary indexes. The uniqueness sentinel a UNIQUE secondary index keeps in the shared
__tidesdb_uniq column family is the exception. The same value can be written and removed any
number of times over a sentinel’s life, so its delete is a regular tombstone.
Primary-CF single-delete, opt-in
The primary row CF is different. UPDATE ... SET non_pk_col writes a fresh row at the same key, a
put over a put, and REPLACE INTO on a table without secondary indexes overwrites silently for the
same reason. Either pattern leaves more than one put under a later single-delete, outside the
library’s contract, and the engine cannot tell from outside which rows those are. So primary-CF
single-delete is behind the session variable tidesdb_single_delete_primary, default OFF. Enabling it is a promise
that the session performs no UPDATE on non-PK columns, no REPLACE INTO or
INSERT ... ON DUPLICATE KEY UPDATE on the silent-overwrite path, and that a new row for a given PK
is always preceded by a DELETE of that PK.
SET SESSION tidesdb_single_delete_primary = 1;INSERT INTO events (...) VALUES ...; -- monotonic PKDELETE FROM events WHERE ts < NOW() - INTERVAL 1 HOUR;Leave it OFF for any session that may issue those statements, since they break the at-most-one-put promise the library’s API asks of every single-delete caller.
Tombstone-density trigger
Single-delete handles the insert-then-delete shape where the contract holds. For everything else,
UPDATEs of indexed columns, REPLACE INTO on tables without secondary indexes, mixed OLTP,
tombstones accumulate inside SSTables until a compaction at the largest level reclaims them, and a
read over a deleted region pays for every tombstone the merge iterator skips.
The tombstone-density trigger lets the engine act on this without waiting for a capacity or
file-count trigger. When the library’s compaction planner evaluates a column family, it asks whether
any one SSTable, at any level, has a tombstone count divided by entry count at or above a ratio
while holding at least a minimum entry count. A single witness escalates compaction. The
TOMBSTONE_DENSITY_TRIGGER and TOMBSTONE_DENSITY_MIN_ENTRIES
table options arm it, and the aggregates show up in the
Tidesdb_total_tombstones, Tidesdb_tombstone_ratio, Tidesdb_max_sst_tombstone_density, and
Tidesdb_max_sst_tombstone_density_level status variables and in the
Tombstones block of SHOW ENGINE TIDESDB STATUS.
Compact after a range delete
Sliding-window expiry, tenant eviction, and time-bucketed log rotation share a shape, a multi-row
DELETE over a known primary-key range followed by a wait for compaction to reclaim the space. A
caller that already knows the range can skip the wait with
tidesdb_compact_after_range_delete_min_rows:
SET SESSION tidesdb_compact_after_range_delete_min_rows = 100000;DELETE FROM events WHERE ts < NOW() - INTERVAL 30 DAY;The default 0 disables it. A non-zero value is both an opt-in and a row-count threshold, so only a
DELETE touching at least that many rows triggers the synchronous compaction and a one-row DELETE
never pays for it. The engine tracks the comparable minimum and maximum primary-key bytes seen during
the statement, a comparison against each bound per delete_row with no extra scan or locking, and on
end_bulk_delete queues that range on the transaction, with an end bound just past the last
deleted key. Secondary-index tombstones are not compacted this way, because a PK range does not
bound a secondary-index range, and are left to the tombstone-density trigger. After the transaction
commits, the engine flushes the memtable once so the tombstones reach SSTables, then compacts each
queued range on the primary row CF synchronously on the committing thread, so the commit returns
only after it finishes. Under autocommit that is the DELETE itself. The threshold should be high
enough that the compaction time is small relative to the DELETE that triggered it. A transaction
that rolls back compacts nothing. A compaction already running over the column family absorbs the
request, so TDB_ERR_LOCKED is ignored quietly, and any other flush or compaction failure is logged
as a warning in the error log and does not fail the commit.
Range tombstone for a whole-range delete
When a multi-row DELETE removes every row across a contiguous primary-key span, the engine writes one range tombstone over the span instead of a per-row tombstone for each key. One interval on the WAL and in the memtable replaces thousands, and compaction drops the whole span in a single step rather than carrying each tombstone through the levels.
The engine cannot know upfront that a DELETE is a clean range, so it works it out as it goes.
Through the statement it buffers each deleted primary-row key and holds its tombstone back rather
than writing it. With the tombstones deferred, every row the statement deleted is still live, so on
end_bulk_delete the count of live rows in the touched [min, max] span equals the number of
buffered keys exactly when the statement removed the whole span. In that case one
tidesdb_txn_delete_range replaces the run. A survivor, from a residual WHERE condition, an
IN-list gap, an index-condition-pushdown rejection, or an ORDER BY ... LIMIT that picks
scattered rows, leaves more live rows than buffered keys, and the buffer falls back to per-row
tombstones with no change in result. The whole thing lives in the transaction, so a ROLLBACK
restores the rows like any other write.
Deferral applies only to a table with an explicit primary key. It is skipped for a table with any trigger, since a deferred tombstone must never hide a row from a trigger reading the table mid-statement, and under Galera, where a deferred range tombstone would not line up with the per-row certification the write path already issues. Secondary-index entries are still deleted per row, because a primary-key range does not bound a secondary-index range, so the range tombstone covers the primary row CF alone. A run of fewer than 64 buffered rows is written as per-row tombstones without the completeness check, which would cost more than it saves. While it defers, the DELETE also holds off the batch commits described below. A delete larger than an internal cap of 262,144 keys flushes its buffer to per-row tombstones and finishes on the ordinary path, which keeps the buffered keys bounded to a few megabytes. When the range tombstone is written it already reclaims the span, so the compact-after-range-delete pass above is skipped for that statement.
Batched bulk statements
A multi-row INSERT or DELETE that MariaDB runs as a bulk operation commits its writes in batches
rather than holding the whole write set in one transaction, but only when the statement is its own
transaction, under autocommit or as the copy step of DDL such as ALTER TABLE. Inside BEGIN or
with autocommit=0 every write stays in the transaction, so its COMMIT or ROLLBACK covers all of
it. An UPDATE does not batch, because the engine does not advertise HA_CAN_FORCE_BULK_UPDATE. The
engine counts one operation per row plus one per secondary index, and when the count reaches 500
(TIDESDB_BULK_INSERT_BATCH_OPS) it commits mid-statement. It does not reset the committed
transaction. It begins a fresh READ COMMITTED transaction for the rest of the statement and parks
the committed one until the transaction ends, because the source table of an INSERT ... SELECT,
the scan of a batched DELETE, or an ALTER copy can still be iterating under it, and a reset
would end that scan early. Rows committed by a batch are durable, so a later failure in the same
statement cannot roll them back. Under Galera a statement other than DDL commits as a whole instead,
since a batch committed before the statement’s write set was certified would stay on this node
alone if the statement then failed. DDL runs on every node in total order and replicates no rows, so
its copy still batches.
Because only autocommit and DDL statements batch, and the engine already runs both at READ
COMMITTED, the first batch commits at READ COMMITTED like every later one. A statement inside
BEGIN keeps the session’s level and its snapshot, since it never batches.
A DELETE with no WHERE clause takes this same row-by-row path inside its transaction, because the
engine declines delete_all_rows, and on a table with an explicit primary key the range tombstone
above can collapse it into one interval. Only TRUNCATE TABLE drops and recreates the table’s
column families.
Backpressure absorption
When ingest outruns flush, the library applies backpressure at its L0 admission policy, making a
commit dwell or wait for the flush queue to drain before it is admitted. This happens inside the
library, so a writer is slowed rather than failed, and the effect is visible in the write-stall
status variables, Tidesdb_writes_throttled, Tidesdb_writes_blocked,
Tidesdb_write_stall_us, and Tidesdb_write_stall_ceiling_hits. A sustained rise in the
ceiling-hits counter is the sign that flush is not keeping up with ingest.
Only when the library’s own no-progress budget is spent, with the memtable, flush queue, or L0
backlog still at its cap, does it return TDB_ERR_MEMORY_LIMIT to the plugin. The engine maps that
to HA_ERR_LOCK_WAIT_TIMEOUT (client error 1205 inside a statement), which is the accurate name
because nothing is locked, counts it in Tidesdb_stmt_memory_limit, and the statement can be
retried once flush has caught up.
Transient contention the library reports as TDB_ERR_LOCKED, typically a memtable rotation that
outran a single call’s own retries, is absorbed in the engine as well. Its read, write, delete,
iterator-open, and iterator-seek wrappers ask again up to 16 times with a short growing backoff,
about 60 milliseconds in total, stopping early if the session is killed, and only then surface a lock wait timeout. A commit is never
retried, because a failed commit leaves the transaction aborted, and neither is a step to the next or
previous entry, because a failed step does not say where it left the iterator. In a query or a
write, an iterator call that still fails ends the statement with an error rather than as the end
of the data, so a scan does not hand back part of its rows as the whole result.