Sixty-four berths, and a hatch that asks what you've done to deserve one. 📦
An admission-controlled LRU for Ruby. It keeps the most recently used keys in order, names the one it would throw out next, and refuses to throw it out for a newcomer that isn't worth more. You store the payloads and count the hits. It decides what stays.
gem 'cache_machines'bay = CacheMachines::Lru.new
# key, candidate frequency, victim frequency: positional, like the Rust core.
bay.admit(42, 1, 0) # => :inserted
bay.admit(7, 1, 0) # => :inserted
bay.access(42) # => :hit
bay.keys # => [42, 7]
bay.remove(7) # => :removedKeys are Integers. Pair them with your own storage, hand the decisions to the bay, and the bay tells you what to keep.
So you built an LRU cache. Hot rows stay, cold rows fall off the back, dashboards go green. Then the nightly export job runs. It reads every row in the table exactly once, and every one of those reads is a cache miss that inserts a key you will never ask for again.
By the time the export finishes, your working set is gone. The 64 keys that got hit ten thousand times each have been shoved out by 64 keys that got hit once. Monday morning is a cold start, and the LRU did exactly what you told it to do.
LRU answers "what was used least recently?" It never asks "does the newcomer deserve the slot?" This gem asks. When the bay is full, a candidate has to show up with a higher frequency than the key it would evict. Ties lose. The export job's keys never make it past the hatch.
AMS Denning, NCC-4203, Denning-class cache tender. The hold is finite. The universe of possible consignments is not.
The tender has 64 berths and no more. Every consignment that arrives is logged by key, and the manifest is kept in order of last use. When the bay is full and something new arrives, the quartermaster names the berth at the back, asks how often each has been requisitioned, and only swaps if the newcomer wins outright.
Somewhere in the fleet, a supply officer proposes:
def fetch(key)
@cache[key] ||= load(key) # Hash with 64 entries, oldest shifted off
end"That," says the quartermaster, "is how one inventory scan empties the bay."
What happens to a key offered to admit:
stateDiagram-v2
[*] --> offered
offered --> hit: key already aboard, promoted to front
offered --> inserted: bay has a free berth
offered --> outranked: bay full, candidate frequency <= victim frequency (refused)
offered --> evicted: bay full, candidate frequency > victim frequency
evicted --> [*]: Evicted carries the victim key
hit --> [*]
inserted --> [*]
outranked --> [*]: nothing changed
And the night of the export job:
sequenceDiagram
participant App
participant Bay as Lru (full)
participant Store as Your storage
App->>Bay: victim
Bay-->>App: 17 (seen 4000 times)
App->>Bay: admit(export row 9001, 1, 4000)
Bay-->>App: :outranked
Note over App: don't cache the row, serve it and move on
App->>Bay: access(17)
Bay-->>App: :hit, promoted
App->>Bay: victim
Bay-->>App: 23 (seen 2 times)
App->>Bay: admit(user 555, 9, 2)
Bay-->>App: Evicted(key 23)
App->>Store: delete 23, store 555
admit(key, candidate_frequency, victim_frequency) takes the key, how often the candidate has been requested, and how often the current victim has. It answers :inserted, :hit, or, only when something was displaced, an Evicted carrying the evicted key. A key that didn't get in is a refusal symbol.
bay = CacheMachines::Lru.new
bay.admit(1, 5, 0) # => :inserted
bay.admit(1, 5, 0) # => :hitWhile there's a free berth, frequencies don't matter. The candidate gets in. A key already aboard is a :hit and moves to the front.
Fill all 64 berths and the frequencies start to count.
bay = CacheMachines::Lru.new
64.times { |key| bay.admit(key, 0, 0) }
bay.victim # => 0
bay.admit(100, 9, 9) # => :outranked
bay.admit(100, 8, 9) # => :outranked
bay.admit(100, 10, 9) # => #<data CacheMachines::Evicted key=0>Equal loses. Lower loses. A refused candidate changes nothing: the victim is still the victim. Only strictly higher gets a berth, and then you are told exactly which key just left so you can drop its payload.
A hit promotes the key to the front and answers :hit. A miss answers :miss, inserts nothing, and changes nothing. Reading is never admitting. Both are answers, not refusals, and both are truthy: compare with ==, don't test with if.
bay = CacheMachines::Lru.new
bay.admit(1, 0, 0)
bay.admit(2, 0, 0)
bay.keys # => [2, 1]
bay.access(1) # => :hit
bay.keys # => [1, 2]
bay.access(9) # => :miss
bay.size # => 2victim is the key at the back of the line, the one admit would evict. It is nil until the bay is full. Ask for it right before you admit, look up its frequency, and pass that in.
bay = CacheMachines::Lru.new
bay.victim # => nil
64.times { |key| bay.admit(key, 0, 0) }
bay.victim # => 0
bay.access(0)
bay.victim # => 1bay = CacheMachines::Lru.new
bay.admit(1, 0, 0)
bay.remove(1) # => :removed
bay.remove(1) # => :absent, already gone: nothing changes
bay.clear # => nil
bay.empty? # => truekeys is a snapshot, most recent first. Mutate the array all you like; the bay doesn't notice.
-
Ruby mirrors the Rust core.
admit(key, candidate_frequency, victim_frequency)is positional, in the order ofLru::admit.access,remove,clear,keys,victimandcapacityare the core's methods under the core's names;sizeandempty?arelenandis_empty. -
Capacity is 64, exposed as
CacheMachines::Lru::CAPACITYand#capacity. The constructor takes no arguments. -
Keys and frequencies are Integers in
0..2**64-1. Floats, strings, nil, and objects withto_intraiseTypeError. Negative or too large raisesRangeError. Bad input is rejected before anything changes.ArgumentErroris only ever Ruby's own, for the wrong number of arguments. -
Refusals don't raise and don't change anything.
admitis the only call that refuses::outrankedwhen the bay is full and the candidate's frequency doesn't exceed the victim's,:zero_capacityfor a policy with no berths at all (the Rust core can be built that way; this gem's bay never is). Every reason has its own symbol, the snake case of the core'sRefusalvariant; there is no catch-all. -
Repeats are honoured.
removeof a key that isn't aboard is:absent: the effect you asked for already holds, and nothing changes.admitoraccessof the key already at the front is:hitand moves nothing. -
Evictedis a value, and values are valid or don't exist.CacheMachines::Evicted.new(key)is checked by the native extension whether the bay built it or you did: a non-Integer raisesTypeError, a key outside0..2**64-1RangeError.==compares it by value. It is sealed: subclassing it raisesTypeErrorandallocateis gone. Sealing stops accidents, not reflection (a namedclass X < Evictedstill bindsXbefore raising, andData#initializecan be bound directly), but the bay reads noEvictedfrom you, so a forged one reaches nothing and never equals a real answer. -
An eviction is never lost.
admitdecides on a copy of the bay and keeps it only once the answer exists: if building theEvictedraises, nothing was admitted or evicted. If building it changed the bay (a redefinedEvicted.newcalling back in),admitraisesRuntimeErrorand is not applied; the inner change stands. If building it froze the bay,admitraisesFrozenErrorand is not applied. TheEvictedclass is resolved once, when the extension loads: reassigning the constant later changes nothing the bay builds. -
No scopes, no restarts to plan for. The bay issues no ids or handles; keys are yours. A new bay is empty.
-
No clock. Frequencies are counts you measured, not times. The bay reads no clock and records none.
-
Every answer, by method.
Method Honoured Refused Records the clock admit(key, candidate_frequency, victim_frequency):hit(promoted),:inserted, anEvicted:outranked,:zero_capacityno clock, nothing recorded access(key):hit(promoted),:miss(nothing changes)never refuses no clock, nothing recorded remove(key):removed,:absent(a repeat: nothing changes)never refuses no clock, nothing recorded clearnilnever refuses no clock, nothing recorded keys,size,empty?,victim,capacitya fresh Array, Integer, true/false, the victim key ornil, Integernever refuses no clock, nothing recorded Evicted.new(key)the value never refuses; an invalid key raises RangeError(TypeErrorfor a non-Integer)no clock, nothing recorded -
Frequencies are yours. The bay never counts hits. It compares the two numbers you give it and nothing else.
-
Frozen means frozen. After
freeze, every mutator raisesFrozenError, including a miss onaccessand aremoveof a key that isn't there.
The export touches more rows than you have berths. Doubling the bay means it takes twice as long to flush, then flushes anyway. The fix is not more room. The fix is a hatch that asks whether the newcomer has earned a berth, and the answer for a row read once is no.
The bay holds keys and order, nothing else. Payloads live in your store, and when admit answers an Evicted you delete that payload yourself. Frequency counting is yours too, whether that's a counter per key, a sketch, or a column you already have. Treat victim then admit as one step in your code; the bay won't stop another thread from slipping in between.
MIT License. See LICENSE.txt for details.
Remember: The export job will read every row once. Make sure once is not enough.