| A memtable is freed only after readers drain | Memtable and WAL | test_l0_retire_is_observable_to_a_bracketed_read; TSan across the suite |
| A lookup’s level-0 walk reports “not yet known”, never “absent” | Memtable and WAL | Unguarded — the skip list’s descent settles on a predecessor and then walks level 0 to the key, because an insert can splice a smaller key in between. Stopping that walk on a key below the target reports a present key as missing, and so does running its budget out and calling the result absent; the walk therefore continues while it is below, is bounded by the list’s own entry count plus slack rather than by a flat constant, and hands back a retry that re-descends rather than an absence. test_skip_list_lookup_never_misses_a_present_key_under_splicing guards the coarse form of this, but it does not reach the narrow window — reinstating a single-load hop still passes it, because landing an insert between a descent finishing and its next load needs an interleaving a test cannot force. fuzz_conc is the only other cover |
| A reclaim never retires the segment taking appends | Value log | test_retire_refuses_the_segment_taking_appends — calls retire on the active slot directly, which is the same question the race asks, and asserts the store keeps writing and reading afterwards. A reclaim reads the active slot per segment rather than once per pass, and the retire re-reads it after claiming the eviction window; deciding against one stale reading would let a roll mid-pass hand it the segment taking appends, whose file it would then unlink |
| No engine-internal error code reaches a public caller | Error codes | Unguarded — tdb_public_rc in db.c is the single choke point that maps TDB_ERR_BUSY to TDB_ERR_LOCKED, and nothing fails if a new public function forwards an engine result without it; the code db.h does not define would reach a caller’s switch and tidesdb_strerror would call it unknown |
| The plan memo is keyed on the shape and the reclamation floor together | Compaction | test_engine_scheduler_replans_when_the_gc_floor_moves — opens a snapshot transaction and asserts the family is planned again with no write having landed |
| A subdivided merge writes the same files as an undivided one | Compaction | test_compaction_subdivided_merge_matches_one_thread — runs the same merge with and without permission to split, and requires the same output count and the same value for every key. the split is at the boundaries the sink already rolls on, so the files are the same files; if that stopped holding, a merge’s result would depend on how many threads were free, which is not a thing a storage engine may do |
| A plan that produced jobs is never memoized | Compaction | Unguarded — proving it needs a compaction job to fail while the layout stays put, which the suite has no way to force without fault injection. The failure is silent: the family simply stops being scheduled, and on an idle database that is permanent. Emptiness is read from the plan’s job count and from nothing else — deriving it from whether some allocation succeeded would let a failed malloc memoize a family that did have work |
| An idle non-empty memtable is rotated on a timer | Memtable and WAL | test_engine_idle_flush_rotates_an_unwritten_memtable — writes under the rotation threshold and asserts the data reaches L1 with no further write |
| Only the handle that installed the log sink closes it | Troubleshooting | test_engine_log_to_file_writes_into_the_database_directory — opens a second database without log_to_file and asserts the first one’s file stopped growing rather than being truncated or reopened |
| An undecided prepare pins its own log generation and no other | Transactions and MVCC | test_engine_undecided_prepare_pins_only_its_own_generation — one prepare left in doubt across six flushed generations; keyed on a database-wide count instead it retained all seven |
| A batch recovered in doubt keeps its log across later flushes | Transactions and MVCC | test_engine_recovered_in_doubt_batch_survives_later_flushes — reopens, writes and flushes without ever asking for the in-doubt list, then decides the batch |
| A snapshot’s references are dropped on every path out, including the mismatch | Compaction | Unguarded — a layout that grew past the caller’s array references nothing, but one that shrank between sizing and filling is referenced in full and returns a count that does not match, so a caller treating the mismatch as a failure still owes those references. Forcing that race in a test is not currently possible. Every call site now releases across the whole zeroed array, which is the only form that cannot leak; the one that returned early instead leaked a reference per table on each attempt, pinning it for the life of the process |
| A family’s reported size counts its key logs exactly once | Statistics | test_engine_cf_data_size_counts_key_logs_once — asserts it equals both the sum of level_sizes and the real bytes on disk; adding the summed value length on top doubles it for any workload whose values stay inline |
| A borrowed root is never released by the descent that used it | Life of a read | test_get_borrows_a_root_held_across_reads — reads a tree whose root is internal through a cache too small to hold it, so the root’s frame is evicted while pinned and a descent that released the borrowed node would drop the sstable’s own reference and free it under the next reader |
| A log that will never be written to is unlinked, not just closed | Memtable and WAL | test_engine_preparing_a_spare_log_strands_no_file — releases sixteen preparers together and asserts at most one log joined the directory; closing the loser’s descriptor without unlinking its file stranded one log per losing committer per rotation, and each one is scanned, replayed and given a memtable on every later open |
| An entry lapses wherever it lives, not only in the memtable | Life of a read | test_ttl_expires_after_reaching_an_sstable — flushes while the deadline is still ahead, so the entry reaches the sstable live and only lapses there. Expiry has to be evaluated on every source a version can be read from; confining it to the skip list and to the flush that converts a lapsed version to a tombstone would leave any lifetime longer than the flush interval never expiring at all |
| Every source ages an entry against the same published second | Life of a read | test_get_ages_entries_against_the_injected_clock — publishes a clock, reads the entry live one second short of its deadline, then advances only that clock and asserts the same read now answers as a tombstone. The memtable’s list and every sstable read one value a ticker publishes rather than calling time(NULL) per entry; two clocks a second apart would let a key read live from one source and lapsed from another, and the read would fall through to whichever answered |
| A tombstone is the only thing that means absent | Data model | test_engine_an_empty_value_reads_back_as_present — stores a zero-length value and requires it present out of the memtable, out of an iterator, out of an sstable after a flush and out of the replayed log after a reopen, while a real delete still reads as not-found. the write path once refused it, and refused it only at apply, after the batch was durable — so the caller saw an I/O error from commit for what was a legitimate write. the model fuzzers now generate empty values about one time in sixteen |
| A lapsed entry is collected by a merge, not by the read that hides it | Compaction | test_ttl_expired_entry_is_collected_by_compaction — asserts the family’s live key total falls across a forced compaction, so the bytes go rather than the key merely reading as absent forever |
| A lapsed entry shadows an older version rather than reading as absent | Life of a read | Unguarded — reading it as absent would let an older version beneath it surface, but the fall-through is not reachable through the public api: a flush triggers a compaction that merges the two versions into one run before any read can observe it, and both mutations of this rule leave the suite green. The sibling case it generalises is guarded at the unit level by test_compaction_keeps_tombstone_with_sibling |
| A scan costs its range, not its family | Iterators | test_cf_iter_narrow_range_scan_does_not_open_every_sstable — 32 disjoint sstables, a four-key scan, and an assertion that fewer than one cache lookup per sstable was taken; unbounded it took 128 for those four keys, which is how concurrent range scans saturated the cores while each wanted almost nothing |
| Pruning sources never drops one a scan needs | Iterators | test_cf_iter_full_scan_spans_every_sstable — an unbounded scan over the same 32 sstables still returns every key, so a selection rule that dropped a live source would fail rather than silently shorten the stream |
| A merge never targets the flush tier | Compaction | test_planner_merge_always_leaves_the_flush_tier — a two-level family with its tier over the file trigger, asserting both the chosen target and the emitted job’s target sit below L1; targeting L1 consolidates the tier in place and leaves it growing one run per flush |
| The tree gains a level when its largest is full | Compaction | test_plan_grows_a_level_when_the_largest_is_full — without it a family stops deepening, the dividing level collapses onto the flush tier, and the tier grows a run per flush with nowhere to drain to |
| A partitioned merge fans out only when no input spans a boundary | Compaction | test_plan_partitioned covers the aligned case fanning out into disjoint jobs; test_plan_partitioned_spanning_input covers the fallback — sharing a spanning input across per-partition jobs lets only the first run, dropping a tombstone without the versions it shadows |
| Ingestion is paced against merge progress as well as flush progress | Memtable and WAL | test_tier_band_paces_against_merge_progress — asserts the tier band admits below its slow mark, dwells further past it, blocks at the stall mark, and is inert when told to weigh no tier; without it a burst outruns compaction and leaves a tier every later read merges across |
| A plan’s jobs run concurrently and the last one out tears the plan down | Compaction | Indirect — ASan and TSan across the suite. The claim is released only when the final job finishes, which is what a family drop waits on, and a queue drain pays the same count through engine_job_group_release |
| A chunk is sized to the object it holds | Block cache | Indirect — ENGINE_NODE_ARENA_CHUNK_SIZE is defined from TDB_DEFAULT_CF_BTREE_BLOCK_SIZE so the two cannot drift, but nothing fails if they do. they had already drifted sixteen-fold, and the only symptom was a cache holding a thirty-second of its budget in useful data — no test covers the ratio of a frame’s chunk to the node inside it |
| The cache charges a frame what it reserves, not what it uses | Block cache | test_arena_reserved_counts_whole_chunks_not_bytes_used — asserts one small object still reserves a whole chunk and that reserved never reads below allocated. a cached node is decoded into its own arena, so the memory a frame holds is the chunk it took; charging the bytes the node used let a 64 MiB cache sit resident at 908 MiB, and nothing reported the gap. worst on a small budget, which is why the capacity test — entries near the nominal size, nothing to round — kept passing |
| The block cache’s frame count follows its byte budget | Block cache | test_cache_reaches_its_configured_capacity — fills past the budget and asserts the cache settles within a few percent of it; a frame ceiling that binds first leaves it at a fraction, silently |
| Each class of file accounts its own device work | Statistics | test_engine_io_stats_attribute_device_work — asserts a fresh database reports zero, that committing moves the log class and flushing moves the sstable class, and that no class reports fewer bytes than writes |
| Every point a writer can wait at reports count, total and longest | Statistics | test_engine_stall_stats_attribute_writer_waits — asserts a fresh database reports zero, that the log wait counts after writes, and that no total is less than the longest wait inside it |
| A finished sstable occupies its data, not its preallocated extent | Block manager | test_engine_finished_sstable_is_trimmed_to_its_data — measures the klog while the database is open, since closing trims it either way and hides the defect |
| A key log the manifest does not name is swept at open | Recovery | test_engine_open_sweeps_orphaned_sstables — plants an unnamed klog and a staging file, then asserts both are gone and every named klog survived |
| The orphan sweep is skipped after a self-heal | Recovery | test_engine_open_keeps_sstables_when_manifest_self_healed — plants a klog the rebuild cannot adopt, so the guard is what keeps it; without the guard the sweep deletes it |
| A merged-away compaction input is unlinked when its last reference drops | Compaction | test_engine_compaction_unlinks_merged_inputs, test_engine_compaction_leaves_no_orphans_under_load — both compare the klog files on disk against the live sstable count, which is the only way the leak is visible; nothing else fails when the files pile up |
| A membership change never waits for a reader | Architecture | test_cf_registry_publish_under_sustained_readers — runs more readers than the machine has cores and watches the publishing thread from outside, because a publish that never returns cannot time itself. Waiting for the borrows to drain is the version that reads as correct and hangs anyway: the counts do fall, but the wait also needs an instant when no reader is inside the borrow guard, and continuously arriving readers never produce one. Under this test that version published 0 of 4; the deferred one publishes in microseconds |
| A flush waits for its install ticket before borrowing the family view, never while holding one | Architecture | test_engine_create_completes_under_sustained_flush — borrowing across the wait stretches one worker’s hold to cover every flush queued ahead of it |
| A level set a clone displaces outlives the borrows that could still read it | Architecture | ThreadSanitizer — level_set_l1_overlap_depth against the level_set_free inside a clone’s reload. The scheduler reads overlap depth off every published family’s set holding only a view borrow, so neither the compaction claim nor unpublishing the family covers it; the set has to be retired rather than freed. Nothing functional fails when it is freed inline — the suite passed, and only the race detector saw it |
| A family the compaction scheduler claimed is not freed under it | Architecture | AddressSanitizer, and only under fuzz_standalone at roughly one run in six — sstable_close on a level set cf_free had already released. The scheduler is the one reader that keeps a family after leaving the view, so the claim rather than the view is what holds it; a drop that reads the claim as clear instead of taking it leaves a window where a tick already inside its borrow claims the family afterwards |
| A dropped family, and a renamed family’s old name, are freed only once no live view names them | Architecture | AddressSanitizer — both were caught by fuzz_conc against a flush still building. Deferring them is not enough on its own: released on the borrow guard they are freed while a flush still holds the view by reference count and reads the handle and the name from it, which is a hold the guard was never sized for. They are released on the live view count instead. No functional test detects either |
| A family’s name and level set are published, never written in place | Architecture | ThreadSanitizer — a rename against a flush copying the name onto an sstable, where it is the first component of a block-cache key, so a torn copy is a wrong key rather than a cosmetic defect |
| A writer waiting on the commit gate or the manifest holds off arriving readers | Architecture | test_engine_create_completes_under_sustained_flush — the lock kind that does this is a glibc extension, so relying on it left the guarantee holding on one platform. The family registry no longer needs it at all: its readers take no lock, so its writers have nothing to be preferred over |
| A committer that finds another rotating does not queue behind it | Memtable and WAL | Unguarded — nothing asserts the acquire is a try rather than a wait. A mutex that hands off by barging starves one waiter for as long as the others keep arriving, and the waiter gains nothing the holder finishing does not already give it |
| A timed wait is taken on a clock the wall clock cannot move | Thread manager | Unguarded — nothing asserts the condvar’s clock. Every background worker parks in the same place, so a wall clock step leaves all of them waiting for a time that has moved away and they stop together for its length; the tell is a burst of work landing when they finally wake |
| A flush never waits out a reader to free a memtable | Memtable and WAL | test_l0_reclaim_defers_rather_than_blocking — it holds an immutable pinned across a retire and asserts the retire returns, which hangs outright if the wait is unbounded |
| A pinned memtable keeps its write-ahead log alive | Memtable and WAL | test_memtable_free_leaves_wal_open |
| Cache frames are never freed; only payloads are reclaimed | Block cache | cache tests; ASan across the suite |
| A cache payload is reclaimed exactly once, by the last reference dropped | Block cache | cache tests; ASan would catch a double free |
| A sstable is freed by whoever drops the last reference | SSTable | level_set, sstable tests; ASan |
| Sources stay pinned for an iterator’s life | Iterators | cf_iter, iter_api_tests; TSan |
| Ingest is paced before the staging ring fills, not when it is full | Memtable and WAL | backpressure tests cover the band, its graduation and its cap; that the append path actually consults it is unguarded, and a regression would show only as a latency tail |
| A copy freezes nothing; it claims what removes files and snapshots the rest | Memtable and WAL | fuzz_standalone and fuzz_conc drive backup and clone against concurrent flush and compaction; nothing asserts the absence of a freeze, so a reviewer reintroducing one would see no failure |
| A caller that mutates the catalogue and cannot finish puts it back | Compaction, Memtable and WAL | test_manifest_an_abandoned_half_batch_lands_on_the_next_commit — buffered records are not discarded when a caller gives up, they wait for the next commit from any path. Both installs name their outputs in a loop and so both must undo a partial one: a compaction that stopped partway would disown its inputs durably with the replacement never named, and the orphan sweep would unlink their files on the next open; a flush would leave the catalogue naming outputs no level set ever took. The clone and the recovery rebuild reach the same end differently — the clone’s failure path drops the destination and the drop cascades those records away, and a failed rebuild aborts the open, whose manifest close discards the batch. The undo itself is unguarded: reaching it needs a refused allocation inside the install’s own window, which test_alloc_failure_through_a_compaction_keeps_every_commit walks the path of but cannot land in |
| An interval delete covering no key is refused where it is buffered | Transactions and MVCC | test_engine_range_delete_refuses_an_interval_covering_nothing — an interval ending where it starts, or before it, was taken when buffered and met only at the apply, which runs after the batch is durable and treats every failure as transient. So it burned the retries and cancelled the whole transaction with an I/O error, losing the writes that shared it, for a bound the caller could still have fixed. The same shape as the empty value above. The rule lives in range_tombstone_interval_valid, which both the API and the tombstone set ask, so the two cannot come to disagree about what an interval is |
| An interval reservation is given back once its batch is visible | Transactions and MVCC | test_engine_repeated_range_deletes_do_not_exhaust_the_reservations — a key reservation is renamed rather than released, so an interval delete is the one claim that has to be handed back explicitly. the table has a fixed number of slots, so leaking one per commit ends with every later interval delete refused as a conflict no retry could clear; the test commits far more than there are slots |
| A copy measures the manifest and reads it inside one hold | Manifest | test_manifest_hold_keeps_the_log_still — a commit past the rollover bound renames a fresh snapshot over the same path, so a length measured outside the hold can be applied to a different file, leaving a catalogue that names klogs the copy does not hold. the test asserts both halves: that the hold blocks every commit, and that those commits really do replace the file once it lifts |
| A descriptor is reclaimed only at the idle reference count | fd manager | reaper, fdmanager tests |
| Every labelled descriptor open pairs with a labelled close | fd manager | test_engine_wal_descriptor_accounting_balances — the gate and the reaper both read the cross-label total, so a label that only decrements drives it negative and silently disables the budget while the reported per-label statistic still looks correct |
| A value-log segment is unlinked only after every reader inside it leaves | Value log | test_concurrent_reads_writes_and_reclaim; TSan across the suite |
| A sealed value-log segment is never modified | Value log | test_reclaim_leaves_the_active_segment_alone, test_recover_appends_to_a_fresh_segment |
| A value id names one block for its whole life | Value log | test_builder_records_vlog_segment_references — nothing is copied within the store, so an id cannot exist in two segments and a crash leaves no duplicate to resolve |
| Value-log liveness comes from what the installed tables report, not the index | Value log | test_reclaim_drops_dead_values — the index names every value whose segment has not been dropped, so counting it alone reports garbage as live and reclamation stops silently |
| A segment a builder may have written to is never reclaimed | Value log | test_reclaim_spares_what_a_builder_may_have_written — values are written before the table naming them is installed, and dropping that segment in the window loses them |
| A value in a segment being emptied is rewritten, not carried | Value log | test_builder_respills_a_value_out_of_a_draining_segment — carrying the reference leaves the output table holding the old segment, and it could never reach zero |
| A value is never split across segments | Value log | test_a_value_larger_than_a_segment_is_not_split — the segment target is a roll threshold, so an oversized value overshoots it whole rather than being divided |
| A segment with a reader inside it is never closed by the reaper | Value log | test_idle_segments_give_back_their_descriptors and the concurrent reclaim test, which evicts while readers are in flight; TSan across the suite |