Skip to content

hot_cache: ContentCache probe-window violations — overflow inserts land unreachable, holes break lookup and allow duplicate keys #584

@justrach

Description

@justrach

Problem

ContentCache is a bounded-probe (4-slot window) table, but three paths violate the window invariant (src/hot_cache.zig):

  1. Overflow eviction strands the new entry. When all 4 window slots are occupied by other keys, putImpl evicts via clockEvict() — a global CLOCK hand that returns a slot anywhere in the table (L161). The new entry is written there, but get only probes the key's own 4-slot window: the entry can never be found. Its bytes (file contents, up to 64MB apiece) sit stranded in the cache while every lookup for that key misses and re-reads from disk. Under collision pressure the cache degrades to a byte-hoarding miss machine.
  2. Holes break lookup. get/remove early-break on !present and key_hash == 0 (L107, L195). A remove resets a slot to empty_slot, so an in-window entry stored past the removed slot becomes unreachable — stranded bytes + permanent misses until eviction happens to reap it.
  3. Duplicate keys. putImpl takes the first empty slot in the window before checking the rest of it, so re-putting a key that lives past a hole inserts a second copy: double memory for the same path, inflated count_, and a stale shadow that serves old bytes if the front copy is ever removed.

All three are memory-hoarding/correctness bugs in the layer that fronts every content read.

Failing Test

test_core.zig — fails on current release tip (scenario 1 trips first; 2 and 3 are also red individually):

test "issue-584: ContentCache probe-window — overflow inserts, holes, and duplicate keys" {
    const fnvBase = struct {
        fn base(key: []const u8, cap: u32) u32 {
            var h: u64 = 14695981039346656037;
            for (key) |b| {
                h ^= b;
                h *%= 1099511628211;
            }
            if (h == 0) h = 1;
            return @as(u32, @truncate(h)) % cap;
        }
    }.base;

    // Collect 5 keys sharing one probe-window base in [1, 60].
    var bufs: [5][16]u8 = undefined;
    var lens: [5]usize = undefined;
    var nkeys: usize = 0;
    var counts = [_]u8{0} ** 64;
    var pick: u32 = 0;
    var i: usize = 0;
    while (i < 4096) : (i += 1) {
        var tmp: [16]u8 = undefined;
        const k = std.fmt.bufPrint(&tmp, "k{d}", .{i}) catch unreachable;
        const b = fnvBase(k, 64);
        if (b < 1 or b > 60) continue;
        counts[b] += 1;
        if (counts[b] >= 5) {
            pick = b;
            break;
        }
    }
    try testing.expect(pick != 0);
    i = 0;
    while (i < 4096 and nkeys < 5) : (i += 1) {
        var tmp: [16]u8 = undefined;
        const k = std.fmt.bufPrint(&tmp, "k{d}", .{i}) catch unreachable;
        if (fnvBase(k, 64) == pick) {
            @memcpy(bufs[nkeys][0..k.len], k);
            lens[nkeys] = k.len;
            nkeys += 1;
        }
    }
    try testing.expectEqual(@as(usize, 5), nkeys);
    const k0 = bufs[0][0..lens[0]];
    const k1 = bufs[1][0..lens[1]];
    const k2 = bufs[2][0..lens[2]];
    const k3 = bufs[3][0..lens[3]];
    const k4 = bufs[4][0..lens[4]];

    // 1) Overflow insert must stay retrievable from its own probe window.
    {
        var cache = try ContentCache.initAlloc(testing.allocator, 64);
        defer cache.deinit();
        try cache.put(k0, "v0");
        try cache.put(k1, "v1");
        try cache.put(k2, "v2");
        try cache.put(k3, "v3");
        try cache.put(k4, "v4"); // window full -> eviction path
        try testing.expect(cache.get(k4) != null);
    }

    // 2) A hole left by remove() must not hide in-window entries behind it.
    // 3) Re-putting such an entry must not create a duplicate copy.
    {
        var cache = try ContentCache.initAlloc(testing.allocator, 64);
        defer cache.deinit();
        try cache.put(k0, "v0");
        try cache.put(k1, "v1");
        try cache.put(k2, "v2");
        cache.remove(k1); // hole between k0 and k2
        try testing.expect(cache.get(k2) != null);
        try cache.put(k2, "v2b");
        try testing.expectEqual(@as(u32, 2), cache.len());
    }
}

Expected

Every entry the cache holds is retrievable via its probe window; a key occupies at most one slot; eviction frees the victim's bytes and the new entry replaces it in-window.

Fix

Keep eviction inside the probe window: second-chance over the 4 window slots (first !ref_bit wins; if all are hot, clear them and take the base slot), free the victim, insert in place. Make putImpl scan the whole window for an existing key before taking the first empty slot, and drop the key_hash == 0 early-breaks in get/remove (4 contiguous probes are cheap). The global clockEvict/hand machinery becomes dead and goes away.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p2Medium priority

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions