Bound the block caches by bytes held instead of by entry count

The read caches sized themselves as MAX_CACHE_SIZE / (SectorSize << shift),
but ctx->shift is only ever assigned on the DDT v1 path, and the context is
zeroed at open. On every DDT v2 image the divisor therefore collapsed to the
sector size and the limit came out as 262144 entries, while each block_cache
entry holds a whole decompressed data block of SectorSize << dataShift bytes,
8 MiB with the default data_shift of 12. That is a ceiling of roughly 2 TiB
rather than the intended 512 MiB, so the count limit was never reached: a
sequential sector-by-sector pass cached the entire decompressed image and
freed none of it until aaruf_close(). Comparing two 40 GB images this way was
killed by the OOM reaper.

An entry count cannot express a memory budget when the values vary in size,
and here they vary by five orders of magnitude: block_header_cache holds
BlockHeader structs while block_cache holds megabyte payloads, yet both were
given the same limit. Track the byte size of each value instead and evict
least-recently-used entries until the cache is back under budget, and drop the
block geometry from cache initialization entirely. The block cache gets the
512 MiB MAX_CACHE_SIZE it always intended; block headers get a separate 8 MiB
budget, which covers far more blocks than the payload cache can hold.

The entry just inserted is never evicted, so callers may keep using the
pointer they handed over for the rest of the call. That also removes the
use-after-free that a zero limit used to cause: the old eviction loop started
at the hash head and could free the entry just added, after which the caller
read from freed memory.
This commit is contained in:
2026-09-02 00:03:22 +01:00
parent c8de84871d
commit 598d9224a7
7 changed files with 71 additions and 48 deletions

View File

@@ -78,6 +78,10 @@
* still enabling efficient sequential and moderate random access patterns. */
#define MAX_CACHE_SIZE 536870912ULL
/** Maximum block header cache size (bytes). Block headers are a few dozen bytes each, so 8 MiB
* keeps headers cached for far more blocks than MAX_CACHE_SIZE can hold payloads for. */
#define MAX_HEADER_CACHE_SIZE 8388608ULL
/** Size in bytes of the fixed LZMA properties header (lc/lp/pb + dictionary size). */
#define LZMA_PROPERTIES_LENGTH 5

View File

@@ -5,6 +5,7 @@
#ifndef LIBAARUFORMAT_LRU_H
#define LIBAARUFORMAT_LRU_H
#include <stddef.h>
#include <stdint.h>
#include <uthash.h>
@@ -24,29 +25,38 @@ struct CacheEntry
{
uint64_t key; ///< 64-bit integer key (unique within the cache).
void *value; ///< Opaque value pointer associated with key.
size_t size; ///< Size in bytes of the memory pointed to by value.
UT_hash_handle hh; ///< uthash handle (must remain per uthash docs).
};
/** \struct CacheHeader
* \brief Cache top-level descriptor encapsulating the hash table root and capacity limit.
*
* The cache enforces an upper bound (max_items) on the number of tracked entries. On insert,
* the oldest entry is evicted when the limit is reached (LRU via uthash insertion order).
* The cache is bounded by the total number of *bytes* held by its values, not by the
* number of entries: the caches in this library store values of wildly different sizes
* (a block header is a few dozen bytes, a decompressed data block is megabytes), so an
* entry-count limit cannot express a memory budget. On insert, the least recently used
* entries are evicted until the cache is back under max_bytes.
*
* Fields:
* - max_items: Maximum number of entries allowed; 0 means unlimited.
* - max_bytes: Memory budget in bytes; 0 means unlimited.
* - cur_bytes: Bytes currently held by the cached values.
* - cache: uthash root pointer; NULL when the cache is empty.
* - free_func: Optional callback to free cached values on eviction/clear.
*
* The entry just inserted is never evicted, so a caller may keep using the pointer it
* handed over for the remainder of the call even if the value is larger than max_bytes.
*/
struct CacheHeader
{
uint64_t max_items; ///< Hard limit for number of entries.
uint64_t max_bytes; ///< Memory budget in bytes for the cached values. 0 means unlimited.
uint64_t cur_bytes; ///< Bytes currently held by the cached values.
struct CacheEntry *cache; ///< Hash root (uthash). NULL when empty.
void (*free_func)(void *); ///< Optional callback to free cached values. NULL if not needed.
};
void *find_in_cache_uint64(struct CacheHeader *cache, uint64_t key);
void add_to_cache_uint64(struct CacheHeader *cache, uint64_t key, void *value);
void add_to_cache_uint64(struct CacheHeader *cache, uint64_t key, void *value, size_t size);
void free_cache(struct CacheHeader *cache);
#endif // LIBAARUFORMAT_LRU_H