Low commit activity in last 3 years
A lightweight, thread-safe in-memory LRU cache with TTL expiration and tag-based bulk invalidation for Ruby applications.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

philiprehberger-cache_kit

Tests Gem Version Last updated

In-memory LRU cache with TTL, tags, and thread safety

Requirements

  • Ruby >= 3.1

Installation

Add to your Gemfile:

gem "philiprehberger-cache_kit"

Or install directly:

gem install philiprehberger-cache_kit

Usage

require "philiprehberger/cache_kit"

cache = Philiprehberger::CacheKit::Store.new(max_size: 500)

# Basic get/set
cache.set("user:1", { name: "Alice" }, ttl: 300)
cache.get("user:1") # => { name: "Alice" }

Fetch (compute-on-miss)

Thread-safe get-or-compute. The block is only called if the key is missing or expired, and the entire operation holds the lock to prevent duplicate computation.

user = cache.fetch("user:1", ttl: 300) do
  User.find(1) # only called on cache miss
end

TTL Expiration

cache.set("session", "abc", ttl: 60) # expires in 60 seconds
cache.get("session")                  # => "abc"
# ... 60 seconds later ...
cache.get("session")                  # => nil

Tag-based Invalidation

cache.set("user:1", data1, tags: ["users"])
cache.set("user:2", data2, tags: ["users"])
cache.set("post:1", data3, tags: ["posts"])

cache.invalidate_tag("users") # removes user:1 and user:2, keeps post:1

Eviction Callback

Register hooks that fire when entries are evicted by LRU pressure or TTL expiry.

cache.on_evict do |key, value|
  logger.info("Evicted #{key}")
end

# Fires on LRU eviction
cache.set("overflow", "data") # triggers callback if cache is full

# Fires on TTL expiry (during get or prune)
cache.prune

Batch Get

Retrieve multiple keys in a single lock acquisition. Returns a hash of found entries only, skipping misses.

cache.set("a", 1)
cache.set("b", 2)

cache.get_many("a", "b", "missing")
# => { "a" => 1, "b" => 2 }

Bulk Set

cache.set_many({ 'a' => 1, 'b' => 2, 'c' => 3 }, ttl: 300, tags: ['batch'])

Compact

evicted = cache.compact  # => 3 (number of expired entries removed)

Refresh

cache.refresh('key', ttl: 600)  # => true (reset TTL without changing value)

Touch

Promote a key to most-recently-used in LRU order, shielding it from the next eviction. Pass ttl: to also reset the entry's expiry to now + ttl seconds. Returns true for live keys and false when the key is missing or expired (expired keys are removed as a side effect).

cache.set('a', 1)
cache.set('b', 2)
cache.set('c', 3)

cache.touch('a')           # => true ('a' is now most recently used)
cache.touch('missing')     # => false

cache.touch('a', ttl: 600) # => true (also resets TTL to 600s)

Peek

Read a value without affecting LRU order or hit/miss counters. Useful for inspection paths that should not influence eviction:

cache.set("user:1", { name: "Alice" })

# Reading via peek does not promote 'user:1' to most-recently-used,
# and does not record a hit or miss.
cache.peek("user:1")     # => { name: "Alice" }
cache.peek("missing")    # => nil
cache.stats              # => { ..., hits: 0, misses: 0, ... }

Returns nil for missing keys and for entries whose TTL has elapsed. Peek does not remove expired entries — call #prune (or #get) to evict them.

Hash-like Access

cache["user:1"] = { name: "Alice" }
cache["user:1"] # => { name: "Alice" }

LRU Eviction

cache = Philiprehberger::CacheKit::Store.new(max_size: 3)

cache.set("a", 1)
cache.set("b", 2)
cache.set("c", 3)
cache.set("d", 4) # evicts "a" (least recently used)

cache.get("a") # => nil
cache.get("d") # => 4

Pruning Expired Entries

cache.set("a", 1, ttl: 1)
cache.set("b", 2, ttl: 1)
cache.set("c", 3)
# ... after TTL expires ...
cache.prune # => 2 (removed expired entries)

Stats

cache.set("a", 1)
cache.get("a")       # hit
cache.get("missing") # miss

cache.stats
# => { size: 1, hits: 1, misses: 1, evictions: 0 }

Stats by Tag

Track per-tag hit, miss, and eviction counters.

cache.set("user:1", data, tags: ["users"])
cache.set("post:1", data, tags: ["posts"])
cache.get("user:1") # hit on "users" tag

cache.stats(tag: "users")
# => { hits: 1, misses: 0, evictions: 0 }

cache.stats(tag: "posts")
# => { hits: 0, misses: 0, evictions: 0 }

TTL Introspection

Read the remaining or absolute expiration without consuming the entry.

cache.set("session", "abc", ttl: 60)

cache.ttl("session")       # => 59.87 (Float seconds)
cache.expire_at("session") # => 2026-04-14 14:41:23 +0000 (Time)

cache.set("permanent", "x")
cache.ttl("permanent")     # => nil (no TTL)
cache.ttl("missing")       # => nil

Bulk Delete

cache.set("a", 1)
cache.set("b", 2)
cache.set("c", 3)

cache.delete_many("a", "b", "missing") # => 2 (count actually removed)
cache.keys                              # => ["c"]

Values

Inspect all non-expired values without affecting LRU order.

cache.set('a', 1)
cache.set('b', 2)
cache.set('c', 3, ttl: 0.05)
sleep 0.1

cache.values # => [1, 2] (expired 'c' excluded; LRU order untouched)

Keys by Tag

Inspect which keys carry a tag without invalidating them.

cache.set("user:1", data1, tags: ["users"])
cache.set("user:2", data2, tags: ["users"])
cache.set("post:1", data3, tags: ["posts"])

cache.keys_by_tag("users") # => ["user:1", "user:2"]
cache.keys_by_tag(:users)  # => ["user:1", "user:2"]
cache.keys_by_tag("none")  # => []

Tag Discovery

List every tag currently in use across the cache.

cache.set("user:1", data1, tags: ["users"])
cache.set("post:1", data2, tags: ["posts"])

cache.tags # => ["posts", "users"]

Atomic Counters

Atomically increment and decrement numeric entries. Missing or expired keys are initialized to 0 before the delta is applied.

cache.increment("views")             # => 1
cache.increment("views")             # => 2
cache.increment("views", by: 5)      # => 7
cache.increment("views", ttl: 3600)  # => 8 (and resets TTL to 3600s)

cache.decrement("quota", by: 2)      # => -2 (if "quota" was missing)

Non-numeric values raise Philiprehberger::CacheKit::Error.

Optimistic Locking

replace_if_equal atomically swaps a value only when the current entry matches the expected value, enabling compare-and-swap workflows without external coordination.

cache.set("flag", "off")
cache.replace_if_equal("flag", "off", "on")  # => true
cache.replace_if_equal("flag", "off", "on")  # => false (value is now "on")

Returns true on a successful swap and false when the key is missing, expired, or holds a different value. Existing tags are preserved; pass ttl: to refresh the expiration at the same time.

Snapshot and Restore

Serialize cache state for warm restarts. The snapshot captures all entries with their remaining TTL, tags, and LRU order.

# Save state before shutdown
data = cache.snapshot
File.write("cache.bin", Marshal.dump(data))

# Restore on startup
new_cache = Philiprehberger::CacheKit::Store.new(max_size: 500)
new_cache.restore(Marshal.load(File.read("cache.bin")))

API

Method Description
Store.new(max_size: 1000) Create a cache with max entries
Store#get(key) Get a value (nil if missing/expired)
Store#peek(key) Read a value without affecting LRU order or hit/miss counters
Store#set(key, value, ttl:, tags:) Store a value
Store#fetch(key, ttl:, tags:, &block) Get or compute a value (thread-safe)
Store#delete(key) Delete a key
Store#invalidate_tag(tag) Remove all entries with a tag
Store#clear Remove all entries
Store#size Number of entries
Store#key?(key) Check if a key exists and is not expired
Store#keys Returns all non-expired keys
Store#values Returns all non-expired values (does not affect LRU order)
Store#[](key) Hash-like read (alias for get)
Store#[]=(key, value) Hash-like write (alias for set without TTL/tags)
Store#stats Returns { size:, hits:, misses:, evictions: }
Store#stats(tag: name) Returns { hits:, misses:, evictions: } for a tag
Store#prune Remove all expired entries, returns count removed
Store#on_evict { |key, value| } Register eviction callback
Store#get_many(*keys) Batch get, returns { key => value } for found entries
#set_many(hash, ttl:, tags:) Bulk set multiple entries
#compact Prune expired entries, return eviction count
#refresh(key, ttl:) Reset TTL without changing value
Store#touch(key, ttl:) Promote a key to most-recently-used; optionally reset TTL
Store#snapshot Serialize cache state to a hash
Store#restore(data) Restore cache state from a snapshot
Store#ttl(key) Remaining seconds until expiry (nil if none/missing/expired)
Store#expire_at(key) Absolute expiration Time (nil if none/missing/expired)
Store#delete_many(*keys) Bulk-delete, returns count removed
Store#keys_by_tag(tag) Keys associated with a tag (non-expired)
Store#tags Sorted list of all tags currently in use across non-expired entries
Store#increment(key, by:, ttl:) Atomic numeric increment
Store#decrement(key, by:, ttl:) Atomic numeric decrement
Store#replace_if_equal(key, expected, new_value, ttl:) Compare-and-swap; returns true on a successful swap, false otherwise

Development

bundle install
bundle exec rspec
bundle exec rubocop

Support

If you find this project useful:

Star the repo

🐛 Report issues

💡 Suggest features

❤️ Sponsor development

🌐 All Open Source Projects

💻 GitHub Profile

🔗 LinkedIn Profile

License

MIT