Embedded key-value store
Redis-like caching. Zero infrastructure.
One SQLite file replaces the cache server. You get set, get, TTLs, atomic counters, eviction and process-safe locks. Each language has its own native implementation, and they all read and write the same file.
pip install lytecache
implementation 'io.github.lytecache:lytecache:0.2.0'
<dependency>
<groupId>io.github.lytecache</groupId>
<artifactId>lytecache</artifactId>
<version>0.2.0</version>
</dependency>
npm install lytecache
go get github.com/lytecache/lytecache-go
composer require lytecache/lytecache
from lytecache import LyteCache
cache = LyteCache()
cache.set("hits", 0)
cache.incr("hits")
import io.lytecache.LyteCache;
try (LyteCache cache = new LyteCache()) {
cache.set("hits", 0L);
cache.incr("hits");
}
import { LyteCache } from "lytecache";
const cache = new LyteCache();
cache.set("hits", 0);
cache.incr("hits");
cache, _ := lytecache.New()
defer cache.Close()
cache.Set("hits", 0)
cache.Incr("hits", 1)
use Lytecache\LyteCache;
$cache = new LyteCache();
$cache->set("hits", 0);
$cache->incr("hits");
Zero config
The first write creates the file, directories, and schema. No init step.
One file format
Every implementation reads and writes the same schema, down to how counters are stored.
Atomic under load
Counters and locks are single SQL statements, correct across threads and processes.
Nothing to run
WAL-mode SQLite instead of a daemon. Ships inside your process.
Start here
Quickstart
Add the dependency and start calling it. There is no path to configure and no migration to run.
from lytecache import LyteCache
cache = LyteCache() # no path, no setup -- just works
cache.set("user:42", {"name": "Samson"}, ttl=300)
cache.get("user:42") # {"name": "Samson"}
cache.incr("hits") # 1
import io.lytecache.LyteCache;
import java.time.Duration;
try (LyteCache cache = new LyteCache()) {
cache.set("user:42", "Samson", Duration.ofMinutes(5));
cache.getString("user:42"); // "Samson"
cache.incr("hits"); // 1
}
import { LyteCache } from "lytecache";
const cache = new LyteCache(); // no path, no setup -- just works
cache.set("user:42", { name: "Samson" }, { ttl: 300 });
cache.get("user:42"); // { name: "Samson" }
cache.incr("hits"); // 1
cache, _ := lytecache.New() // no path, no setup -- just works
defer cache.Close()
cache.Set("user:42", map[string]any{"name": "Samson"}, lytecache.TTL(5*time.Minute))
var user map[string]any
cache.Get("user:42", &user) // {"name": "Samson"}
cache.Incr("hits", 1) // 1
use Lytecache\LyteCache;
$cache = new LyteCache(); // no path, no setup -- just works
$cache->set("user:42", ["name" => "Samson"], ttl: 300);
$cache->get("user:42"); // ["name" => "Samson"]
$cache->incr("hits"); // 1
Close the cache when you're done with it. Each language has its own idiom: with LyteCache() as cache: in Python, try (LyteCache cache = new LyteCache()) in Java, using cache = new LyteCache() in Node.js (or call cache[Symbol.dispose]() directly), and defer cache.Close() in Go. PHP closes the cache in __destruct(), so you never have to call $cache->close() yourself. Closing flushes buffered state and stops the background sweeper. PHP has no background sweeper; TTL & expiration explains what it does instead.
Start here
Your first cache
About ten minutes, one script. You'll cache a slow HTTP call, share the cache between two processes, and look at the result from a shell. The tutorial uses Python. The other four languages work the same way, and every reference group shows their method names on the toggle.
-
Install it and store one value
Run
pip install lytecache, then try the smallest program that does something:Pythonfrom lytecache import LyteCache cache = LyteCache() cache.set("greeting", "hello") print(cache.get("greeting"))It prints
hello. You didn't start a server or choose a file. The first write created the file, and step 5 shows you where it is. -
Make a slow call twice
Save this as
slow_api.py.httpbin.org/delay/1waits a second before it answers, standing in for any slow or rate-limited API.slow_api.pyimport json import time import urllib.request from lytecache import LyteCache cache = LyteCache() URL = "https://httpbin.org/delay/1" # waits one second on purpose def fetch(url): key = f"http:{url}" cached = cache.get(key) if cached is not None: return cached with urllib.request.urlopen(url) as resp: data = json.load(resp) cache.set(key, data) return data for attempt in (1, 2): start = time.perf_counter() fetch(URL) print(f"call {attempt}: {(time.perf_counter() - start) * 1000:.2f} ms")Terminal$ python slow_api.py call 1: 1399.60 ms call 2: 0.10 msThe first call went over the network and took 1.4 seconds. The second came from the cache in a tenth of a millisecond: one SQLite read from a file on your disk. Your numbers will differ. The gap won't.
-
Give the data an expiry
Right now the response is cached forever. Change the
setline to give it a TTL:slow_api.pycache.set(key, data, ttl=300) # secondsChoose the number by asking how stale an answer you can live with. An exchange-rate feed that updates every few minutes is fine at 300 seconds. A user's own profile, which they expect to change the moment they edit it, shouldn't wait for a TTL at all: delete it when it changes (see Invalidate on write). The unit is seconds, so
ttl=300000would be 83 hours.Run the script now and both calls still hit the cache. The entry from step 2 was written without a TTL, and
fetch()only writes on a miss, so your change hasn't touched it. A new TTL only applies to entries written after the change, which is worth remembering in production too. Clear the cache once and run it again:Terminal$ python -c "from lytecache import LyteCache; LyteCache().flush()" $ python slow_api.py call 1: 1471.90 ms call 2: 0.10 ms -
Run it from a second process
Run the script again from the same directory, or from a second terminal:
Terminal$ python slow_api.py call 1: 0.04 ms call 2: 0.02 msBoth calls were hits. This is a brand-new process with nothing in memory, and it found the entry the last run wrote. This is where lytecache stops looking like a dictionary. The cache lives in a file, so every process that opens it shares it, and it survives restarts. A Node.js or Go program started from the same directory would read the same entry.
-
Look at it from the shell
Install the CLI (
brew install lytecache/tap/lytecache; other platforms are in Install) and run it from the same directory:Terminal$ lytecache which /Users/you/Library/Caches/lytecache/fb0e4baed14c.db (exists) $ lytecache keys --long KEY TYPE TTL SIZE http:https://httpbin.org/delay/1 json 299.5 261 $ lytecache get 'http:https://httpbin.org/delay/1' { "args": {}, "data": "", ...whichshows the file your script created. Its name comes from the directory you ran in, so the CLI finds it with no flags as long as you run it from the same place.keys --longshows the key, its type (json, because the response was a dict), the seconds left on its TTL, and its size. Seeing the row is usually what makes the model click: it's data in a file, and anything that can open the file can read it. -
What to read next
The recipes cover what you'll want next: caching API calls with memoize, avoiding a stampede when a hot key expires, and idempotency keys. If something doesn't behave the way this tutorial did, start with Troubleshooting.
Start here
Where is my data?
Every implementation picks the same default file for the same project. Start a Python script and a Go service from the same directory and they share one cache, with nothing to configure.
<platform cache dir>/lytecache/<project-id>.db
| Platform | Default location |
|---|---|
| Linux | $XDG_CACHE_HOME/lytecache/<project-id>.db, or ~/.cache/lytecache/<project-id>.db |
| macOS | ~/Library/Caches/lytecache/<project-id>.db |
| Windows | %LOCALAPPDATA%\lytecache\<project-id>.db |
<project-id> is the first 12 hex characters of the SHA-256 hash of your working directory's resolved absolute path. Every implementation derives it the same way, so a Python script and a Java service started from the same directory land on the same file. Nothing is written into your repo. (The glossary explains SHA-256.)
Overriding it
- Pass an explicit path:
LyteCache("/data/cache.db")in Python,.path(Path.of("/data/cache.db"))on the Java builder,new LyteCache({ path: "/data/cache.db" })in Node.js,lytecache.WithPath("/data/cache.db")in Go,new LyteCache(path: "/data/cache.db")in PHP. - Set
LYTECACHE_PATH=/data/cache.dbin the environment. It overrides the default in every language.
To find out which file a cache is using, ask it: LyteCache.default_path() / cache.path in Python, LyteCache.defaultPath() / cache.path() in Java, LyteCache.defaultPath() / cache.path in Node.js, lytecache.DefaultPath() / cache.Path() in Go, LyteCache::defaultPath() / $cache->path() in PHP. From a shell, lytecache which prints the path and whether the file exists.
Laravel is the one exception. Its cache driver defaults to storage_path('framework/cache/lytecache.db'), because that's where a Laravel app keeps its cache files. Change it with stores.lytecache.path in config/cache.php.
Start here
When to use lytecache
✓ Good fit
- Single-node apps (or single-machine, multi-process apps) that want caching, counters, or TTLs with zero infrastructure
- Scripts, CLIs, notebooks, small web services, background jobs, test fixtures
- A cache that survives process restarts without a separate daemon
- Multi-process coordination via the process-safe distributed lock
- Mixed-language systems, where Python, Java, Node.js, Go and PHP processes share one cache file
- Laravel apps that want
Cache::remember()andCache::lock()without running Redis. Switching the driver is one config line.
✕ Not a good fit
- A cache shared live across several servers. SQLite is a local file, not a network service, so use Redis or Memcached.
- Heavy write traffic from many processes. SQLite allows one writer at a time, so writes queue up and become the bottleneck.
- Pub/sub across machines, streams with consumer groups, sorted sets, or other Redis data structures beyond key-value, counters and hashes. (Events between processes on one host do work; see Local events.)
- Complex queries over cached data. Use a real database.
Guides
Recipes
Task-first answers to the things people actually build with lytecache. Each recipe says when to use it, shows it in all five languages, and ends with the catch.
Cache an API or HTTP response
Use read-through caching when an upstream answer is slow, rate-limited or billed per call, and a slightly old answer is acceptable. memoize checks the cache, calls your function on a miss, and stores what it returns.
Set the TTL from how fast the data really changes, not from how often you call it. Exchange rates that update every few minutes can live for five minutes. A product catalogue edited twice a day can live for an hour.
@cache.memoize(ttl=300) # rates update every few minutes
def exchange_rates(base):
resp = requests.get(f"https://api.example.com/rates?base={base}", timeout=5)
resp.raise_for_status() # an exception is never cached
return resp.json()
exchange_rates("USD") # network
exchange_rates("USD") # cache, for the next 300 seconds
Rates rates = cache.memoize("rates:" + base, Duration.ofMinutes(5),
() -> ratesClient.fetch(base)); // an exception is never cached
// memoizeAsync for a loader that returns a Promise
const rates = await cache.memoizeAsync(`rates:${base}`, 300, async () => {
const resp = await fetch(`https://api.example.com/rates?base=${base}`);
if (!resp.ok) throw new Error(`rates: HTTP ${resp.status}`); // never cached
return resp.json();
});
rates, err := lytecache.Memoize(cache, "rates:"+base, 5*time.Minute,
func() (Rates, error) {
return ratesClient.Fetch(base) // a non-nil error is never cached
})
$rates = $cache->remember("rates:{$base}", 300, function () use ($http, $base) {
return $http->get("https://api.example.com/rates", ["base" => $base])
->throw() // an exception is never cached
->json();
});
Caveat. Whatever your function returns is cached for the full TTL, including a 200 response that contains an error message. Check the response before you return it, as the examples do. memoize also doesn't protect against a stampede when a popular key expires; the next recipe does.
Avoid a cache stampede
When a hot key expires, every request that arrives before it's rebuilt misses at once, and they all hit the slow backend together. If the rebuild is expensive, that pile-up can take the backend down.
Take a lock around the rebuild, then check the cache again once you hold it. The first request rebuilds. The others wait, find the fresh value on their second check, and never touch the backend.
def get_report(day):
key = f"report:{day}"
report = cache.get(key)
if report is not None:
return report
with cache.lock(f"rebuild:{key}", timeout=30):
report = cache.get(key) # rebuilt while we waited?
if report is None:
report = build_report(day) # the slow part
cache.set(key, report, ttl=600)
return report
Report getReport(String day) {
String key = "report:" + day;
Report report = cache.get(key, Report.class);
if (report != null) return report;
try (CacheLock lock = cache.lock("rebuild:" + key, Duration.ofSeconds(30))) {
report = cache.get(key, Report.class); // rebuilt while we waited?
if (report == null) {
report = buildReport(day); // the slow part
cache.set(key, report, Duration.ofMinutes(10));
}
}
return report;
}
function getReport(day: string): Report {
const key = `report:${day}`;
let report = cache.get<Report>(key);
if (report !== undefined) return report;
using lock = cache.lock(`rebuild:${key}`, { timeoutMs: 30_000 });
report = cache.get<Report>(key); // rebuilt while we waited?
if (report === undefined) {
report = buildReport(day); // the slow part
cache.set(key, report, { ttl: 600 });
}
return report;
}
func getReport(day string) (Report, error) {
key := "report:" + day
var r Report
if found, err := cache.Get(key, &r); err != nil || found {
return r, err
}
lock, err := cache.Lock("rebuild:"+key, 30*time.Second)
if err != nil {
return r, err // lytecache.ErrLockTimeout if a rebuild takes too long
}
defer lock.Release()
if found, err := cache.Get(key, &r); err != nil || found { // rebuilt while we waited?
return r, err
}
if r, err = buildReport(day); err != nil { // the slow part
return r, err
}
return r, cache.Set(key, r, lytecache.TTL(10*time.Minute))
}
function getReport(LyteCache $cache, string $day): array
{
$key = "report:{$day}";
$report = $cache->get($key);
if ($report !== null) {
return $report;
}
return $cache->lock("rebuild:{$key}", timeout: 30.0)->block(function () use ($cache, $key, $day) {
$report = $cache->get($key); // rebuilt while we waited?
if ($report === null) {
$report = buildReport($day); // the slow part
$cache->set($key, $report, ttl: 600);
}
return $report;
});
}
Caveat. Waiting requests block for up to the lock timeout, and that timeout is also the lock's TTL. If a rebuild takes longer than the timeout, the lock expires and a second worker starts rebuilding too. Set the timeout well above your slowest rebuild. The lock only coordinates processes on this host.
Serve stale data when an upstream is down
Keep two copies of the answer: a short-lived "fresh" one that decides when you refetch, and a long-lived fallback you only read when the upstream fails. Your users see slightly old data instead of an error.
Store when the data was fetched next to the data, so the UI can say "rates as of 14:02" instead of passing old numbers off as current.
def exchange_rates():
fresh = cache.get("rates:fresh")
if fresh is not None:
return fresh
try:
rates = {"data": fetch_rates(), "fetched_at": time.time()}
except UpstreamError:
stale = cache.get("rates:stale")
if stale is None:
raise # nothing to fall back on
return {**stale, "stale": True} # show "as of fetched_at"
cache.set("rates:fresh", rates, ttl=60) # how often you want new data
cache.set("rates:stale", rates, ttl=24 * 3600) # how old still beats an error
return rates
record Rates(Map<String, Double> data, long fetchedAt, boolean stale) {}
Rates exchangeRates() {
Rates fresh = cache.get("rates:fresh", Rates.class);
if (fresh != null) return fresh;
Rates rates;
try {
rates = new Rates(ratesClient.fetch(), System.currentTimeMillis(), false);
} catch (UpstreamException e) {
Rates stale = cache.get("rates:stale", Rates.class);
if (stale == null) throw e; // nothing to fall back on
return new Rates(stale.data(), stale.fetchedAt(), true);
}
cache.set("rates:fresh", rates, Duration.ofMinutes(1));
cache.set("rates:stale", rates, Duration.ofDays(1));
return rates;
}
async function exchangeRates() {
const fresh = cache.get("rates:fresh");
if (fresh !== undefined) return fresh;
let rates;
try {
rates = { data: await fetchRates(), fetchedAt: Date.now() };
} catch (err) {
const stale = cache.get("rates:stale");
if (stale === undefined) throw err; // nothing to fall back on
return { ...stale, stale: true }; // show "as of fetchedAt"
}
cache.set("rates:fresh", rates, { ttl: 60 });
cache.set("rates:stale", rates, { ttl: 24 * 3600 });
return rates;
}
type Rates struct {
Data map[string]float64 `json:"data"`
FetchedAt time.Time `json:"fetched_at"`
Stale bool `json:"stale"`
}
func exchangeRates() (Rates, error) {
var r Rates
if found, _ := cache.Get("rates:fresh", &r); found {
return r, nil
}
data, err := fetchRates()
if err != nil {
if found, _ := cache.Get("rates:stale", &r); found {
r.Stale = true // show "as of FetchedAt"
return r, nil
}
return r, err // nothing to fall back on
}
r = Rates{Data: data, FetchedAt: time.Now()}
cache.Set("rates:fresh", r, lytecache.TTL(time.Minute))
cache.Set("rates:stale", r, lytecache.TTL(24*time.Hour))
return r, nil
}
function exchangeRates(LyteCache $cache): array
{
$fresh = $cache->get("rates:fresh");
if ($fresh !== null) {
return $fresh;
}
try {
$rates = ["data" => fetchRates(), "fetched_at" => time()];
} catch (UpstreamException $e) {
$stale = $cache->get("rates:stale");
if ($stale === null) {
throw $e; // nothing to fall back on
}
return [...$stale, "stale" => true]; // show "as of fetched_at"
}
$cache->set("rates:fresh", $rates, ttl: 60);
$cache->set("rates:stale", $rates, ttl: 24 * 3600);
return $rates;
}
Caveat. Only do this for data where an old answer is better than no answer. Exchange rates on a dashboard qualify. A balance, or a price you're about to charge, does not. During an outage every request still tries the upstream first, because the fresh copy is gone. If the upstream is slow to fail, also cache the failure for a few seconds and skip the call while it's there.
Rate limit
Count requests per client in a key named after the current time window. incr is atomic, so concurrent requests from many workers are all counted. When the window rolls over, the key name changes and counting starts again from zero.
incr creates a new counter with no TTL, so set one on the first increment. That way old windows clean themselves up.
def allow(client_id, limit=100, window=60):
bucket = int(time.time() // window)
key = f"rate:{client_id}:{bucket}"
count = cache.incr(key)
if count == 1:
cache.expire(key, window * 2) # old windows clean themselves up
return count <= limit
boolean allow(String clientId, long limit, Duration window) {
long bucket = Instant.now().getEpochSecond() / window.toSeconds();
String key = "rate:" + clientId + ":" + bucket;
long count = cache.incr(key);
if (count == 1) {
cache.expire(key, window.multipliedBy(2)); // old windows clean themselves up
}
return count <= limit;
}
function allow(clientId: string, limit = 100, windowSeconds = 60): boolean {
const bucket = Math.floor(Date.now() / 1000 / windowSeconds);
const key = `rate:${clientId}:${bucket}`;
const count = Number(cache.incr(key));
if (count === 1) {
cache.expire(key, windowSeconds * 2); // old windows clean themselves up
}
return count <= limit;
}
func allow(clientID string, limit int64, window time.Duration) (bool, error) {
bucket := time.Now().Unix() / int64(window.Seconds())
key := fmt.Sprintf("rate:%s:%d", clientID, bucket)
count, err := cache.Incr(key, 1)
if err != nil {
return false, err
}
if count == 1 {
cache.Expire(key, 2*window) // old windows clean themselves up
}
return count <= limit, nil
}
function allow(LyteCache $cache, string $clientId, int $limit = 100, int $window = 60): bool
{
$bucket = intdiv(time(), $window);
$key = "rate:{$clientId}:{$bucket}";
$count = $cache->incr($key);
if ($count === 1) {
$cache->expire($key, $window * 2); // old windows clean themselves up
}
return $count <= $limit;
}
Caveat. This is a fixed window. A client can send limit requests at the end of one window and limit more at the start of the next, so a burst of up to twice the limit gets through. If that matters, keep the last two windows and weight them, or use a token bucket. Also, if a process dies between incr and expire, that window's key never expires. The count stays correct, because the next window uses a new key, but the old row lingers until you delete it.
Idempotency keys
A client retries a payment because its connection dropped. Did the first attempt go through? An idempotency key answers that. The client sends the same key on every retry, and your handler makes sure the work runs once per key.
add() is the core of it. It writes the key only if it doesn't exist, as one atomic statement, and tells you whether it won. Two workers can receive the same retry at the same moment. Exactly one gets true and does the work. The other sees the claim and returns the first result, or a 409 if the first attempt is still running.
Store the outcome under the same key when you finish, so a later retry gets the original response rather than a second charge.
DAY = 24 * 3600
def charge(request_id, amount):
key = f"idem:{request_id}"
if not cache.add(key, {"status": "processing"}, ttl=DAY):
previous = cache.get(key)
if previous and previous["status"] == "done":
return previous["receipt"] # replay the first response
raise Conflict("still processing") # 409: tell the client to retry later
try:
receipt = payments.charge(amount, idempotency_key=request_id)
except Exception:
cache.delete(key) # failed: let a retry try again
raise
cache.set(key, {"status": "done", "receipt": receipt}, ttl=DAY)
return receipt
record Claim(String status, Receipt receipt) {}
Receipt charge(String requestId, long amount) {
String key = "idem:" + requestId;
if (!cache.add(key, new Claim("processing", null), Duration.ofDays(1))) {
Claim previous = cache.get(key, Claim.class);
if (previous != null && previous.status().equals("done")) {
return previous.receipt(); // replay the first response
}
throw new ConflictException("still processing"); // 409
}
Receipt receipt;
try {
receipt = payments.charge(amount, requestId);
} catch (RuntimeException e) {
cache.delete(key); // failed: let a retry try again
throw e;
}
cache.set(key, new Claim("done", receipt), Duration.ofDays(1));
return receipt;
}
const DAY = 24 * 3600;
async function charge(requestId: string, amount: number): Promise<Receipt> {
const key = `idem:${requestId}`;
if (!cache.add(key, { status: "processing" }, { ttl: DAY })) {
const previous = cache.get<{ status: string; receipt?: Receipt }>(key);
if (previous?.status === "done") return previous.receipt!; // replay
throw new ConflictError("still processing"); // 409
}
let receipt: Receipt;
try {
receipt = await payments.charge(amount, { idempotencyKey: requestId });
} catch (err) {
cache.delete(key); // failed: let a retry try again
throw err;
}
cache.set(key, { status: "done", receipt }, { ttl: DAY });
return receipt;
}
type claim struct {
Status string `json:"status"`
Receipt *Receipt `json:"receipt,omitempty"`
}
func charge(requestID string, amount int64) (*Receipt, error) {
key := "idem:" + requestID
won, err := cache.Add(key, claim{Status: "processing"}, lytecache.TTL(24*time.Hour))
if err != nil {
return nil, err
}
if !won {
var prev claim
if found, _ := cache.Get(key, &prev); found && prev.Status == "done" {
return prev.Receipt, nil // replay the first response
}
return nil, ErrStillProcessing // 409
}
receipt, err := payments.Charge(amount, requestID)
if err != nil {
cache.Delete(key) // failed: let a retry try again
return nil, err
}
return receipt, cache.Set(key, claim{Status: "done", Receipt: receipt}, lytecache.TTL(24*time.Hour))
}
function charge(LyteCache $cache, string $requestId, int $amount): array
{
$key = "idem:{$requestId}";
$day = 24 * 3600;
if (!$cache->add($key, ["status" => "processing"], ttl: $day)) {
$previous = $cache->get($key);
if (($previous["status"] ?? null) === "done") {
return $previous["receipt"]; // replay the first response
}
throw new ConflictException("still processing"); // 409
}
try {
$receipt = Payments::charge($amount, idempotencyKey: $requestId);
} catch (Throwable $e) {
$cache->delete($key); // failed: let a retry try again
throw $e;
}
$cache->set($key, ["status" => "done", "receipt" => $receipt], ttl: $day);
return $receipt;
}
Caveat. Three things to get right. First, the claim must not be evicted: an LRU eviction would let a duplicate through. We'd keep idempotency keys in their own namespace or file with eviction set to noeviction and no key cap. Second, the claim only stops your workers from starting the work twice. If a process dies after charging but before writing the result, the key stays at processing until it expires. That's why the examples also pass the request ID to the payment provider as its own idempotency key. Third, this only covers workers on one host. Across hosts, use a unique constraint in your database.
A cross-process lock
Use lock() when several workers on one machine share something that only one of them should touch at a time, such as a file, an external account, or a sync with another system. Each worker waits its turn.
"Run this job once" is a different problem. If five workers all fire the nightly export at midnight, a lock makes them run it one after another, five times. To run it once, claim the run itself with add(), using a key that names the run.
# One worker at a time:
with cache.lock("inventory-sync", timeout=120):
sync_inventory()
# Once per day, however many workers fire at midnight:
today = datetime.date.today().isoformat()
if cache.add(f"job:nightly-export:{today}", os.getpid(), ttl=2 * 86400):
run_export()
// One worker at a time:
try (CacheLock lock = cache.lock("inventory-sync", Duration.ofMinutes(2))) {
syncInventory();
}
// Once per day, however many workers fire at midnight:
String today = LocalDate.now().toString();
if (cache.add("job:nightly-export:" + today, ProcessHandle.current().pid(), Duration.ofDays(2))) {
runExport();
}
// One worker at a time:
{
using lock = cache.lock("inventory-sync", { timeoutMs: 120_000 });
syncInventory();
}
// Once per day, however many workers fire at midnight:
const today = new Date().toISOString().slice(0, 10);
if (cache.add(`job:nightly-export:${today}`, process.pid, { ttl: 2 * 86400 })) {
runExport();
}
// One worker at a time:
lock, err := cache.Lock("inventory-sync", 2*time.Minute)
if err != nil {
return err
}
syncInventory()
lock.Release()
// Once per day, however many workers fire at midnight:
today := time.Now().Format("2006-01-02")
if won, _ := cache.Add("job:nightly-export:"+today, os.Getpid(), lytecache.TTL(48*time.Hour)); won {
runExport()
}
// One worker at a time:
$cache->lock("inventory-sync", timeout: 120.0)->block(function () {
syncInventory();
});
// Once per day, however many workers fire at midnight:
$today = date("Y-m-d");
if ($cache->add("job:nightly-export:{$today}", getmypid(), ttl: 2 * 86400)) {
runExport();
}
Caveat. Both only coordinate processes that share this cache file, which means one machine. The lock's timeout is also its TTL: if the work outlasts it, the lock expires and another worker can enter. The daily claim is never released, and that's deliberate, because the date in the key is what makes it once per day. If the export fails and you want a retry to run it, delete the claim in your error handler. (Python's lock() also accepts blocking=False to give up at once instead of waiting.)
Invalidate on write
When the data behind a cache entry changes, delete the entry so the next read fetches the new value. Do it after your database transaction commits, not before and not inside it.
Here's why the order matters. Delete first and a request that arrives before your commit misses, reads the old row from the database, and caches it again. That stale copy then stays until its TTL runs out. Delete after the commit and the next miss can only read the new row.
def rename_user(user_id, name):
with db.transaction():
db.execute("UPDATE users SET name = ? WHERE id = ?", (name, user_id))
cache.delete(f"user:{user_id}") # after the commit, never inside it
void renameUser(long userId, String name) {
jdbc.inTransaction(tx -> tx.update("UPDATE users SET name = ? WHERE id = ?", name, userId));
cache.delete("user:" + userId); // after the commit, never inside it
}
async function renameUser(userId: number, name: string) {
await db.transaction(async (tx) => {
await tx.query("UPDATE users SET name = $1 WHERE id = $2", [name, userId]);
});
cache.delete(`user:${userId}`); // after the commit, never inside it
}
func renameUser(ctx context.Context, userID int64, name string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, "UPDATE users SET name = $1 WHERE id = $2", name, userID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
_, err = cache.Delete(fmt.Sprintf("user:%d", userID)) // after the commit
return err
}
function renameUser(PDO $db, LyteCache $cache, int $userId, string $name): void
{
$db->beginTransaction();
$db->prepare("UPDATE users SET name = ? WHERE id = ?")->execute([$name, $userId]);
$db->commit();
$cache->delete("user:{$userId}"); // after the commit, never inside it
}
Caveat. A small race remains. A reader that loaded the old row just before your commit can write it into the cache just after your delete. That's rare, but it's why cached database rows should always carry a TTL as well: it caps how long a stale copy can survive. If other processes keep their own in-memory copies, deleting the shared entry isn't enough. Tell them, as in the next recipe.
Cross-worker invalidation with notify
This is the case the notification feature exists for. Several workers on one machine keep derived data in memory, such as a parsed config or a memo dict. When one worker changes the source, the others need to drop their copies. Deleting the shared cache entry doesn't reach their memory. An event does.
Publish after the commit, and run the listener on a background thread in each worker.
# the writer, after its transaction commits
cache.delete("pricing-rules")
cache.notify("invalidate", {"key": "pricing-rules"})
# every worker, at startup
def listen():
with cache.watch("invalidate") as sub:
for event in sub:
local_memo.pop(event.payload["key"], None)
threading.Thread(target=listen, daemon=True).start()
// the writer, after its transaction commits
cache.delete("pricing-rules");
cache.notify("invalidate", Map.of("key", "pricing-rules"));
// every worker, at startup
Thread listener = new Thread(() -> {
try (Subscription sub = cache.watch("invalidate")) {
for (Event e : sub) {
localMemo.remove(e.payload(Map.class).get("key"));
}
}
}, "lytecache-invalidate");
listener.setDaemon(true);
listener.start();
// the writer, after its transaction commits
cache.delete("pricing-rules");
cache.notify("invalidate", { key: "pricing-rules" });
// every worker, at startup
(async () => {
using sub = cache.watch("invalidate");
for await (const event of sub) localMemo.delete(event.payload.key);
})();
// the writer, after its transaction commits
cache.Delete("pricing-rules")
cache.Notify("invalidate", map[string]any{"key": "pricing-rules"})
// every worker, at startup
go func() {
sub, err := cache.Watch([]string{"invalidate"})
if err != nil {
log.Fatal(err)
}
defer sub.Close()
for ev := range sub.Events() {
var p struct{ Key string `json:"key"` }
if ev.Decode(&p) == nil {
localMemo.Delete(p.Key) // a sync.Map: other goroutines read it
}
}
}()
// the writer, after its transaction commits
$cache->delete("pricing-rules");
$cache->notify("invalidate", ["key" => "pricing-rules"]);
// a long-running worker (queue worker, Octane, Swoole). PHP has no
// threads, so the worker polls inside this loop.
foreach ($cache->watch("invalidate") as $event) {
unset($localMemo[$event->payload()["key"]]);
}
Caveat. Delivery is polled, so a worker drops its copy within about 50 ms, or up to about a second after a quiet spell. It's also at-least-once, so handlers must be safe to run twice (dropping a key twice is). It only reaches processes on this machine. A worker that starts after the event was published won't see it unless it resumes from a saved cursor, so a fresh worker should start with an empty memo. A plain PHP-FPM request starts empty anyway, so this matters for long-running PHP workers only.
Guides
Coming from Redis
Most of what you use Redis for as a cache maps across one to one. The method names below are the Python ones. Node.js, Java and PHP use camelCase (setMany), and Go uses exported names (SetMany). Every method is on the toggle in the method reference.
| Redis | lytecache | Differences |
|---|---|---|
SET / GET | set(key, value) / get(key) | Values keep their type: string, integer, float, bytes or JSON. A miss returns None, null or undefined, or the default you pass. |
SETEX, SET … EX/PX | set(key, value, ttl=…) | Seconds as a number in Python, Node.js and PHP (fractions allowed, so 0.5 is 500 ms). A Duration in Java and Go. |
DEL | delete(*keys) | Returns how many keys existed. |
EXISTS | exists(key) (has in PHP) | One key, returns a boolean. |
EXPIRE / PEXPIRE | expire(key, ttl) | Same units as set. |
TTL / PTTL | ttl(key) (TTLOf in Go) | Remaining seconds as a float. -1 means no TTL. A missing key returns None/null/undefined where Redis returns -2. |
PERSIST | persist(key) | |
INCR / INCRBY / DECR / DECRBY | incr(key, n) / decr(key, n) | Keeps an existing TTL. A counter it creates has no TTL. Float increments use incr_float (incrFloat, incrDouble in Java). |
MSET / MGET | set_many(mapping) / get_many(keys) | set_many is one transaction. get_many returns a map of the keys it found, not a list with gaps. |
SETNX, SET … NX | add(key, value, ttl) | Returns whether it wrote. Atomic across processes. |
SET … XX | replace(key, value, ttl) | |
GETSET, SET … GET | get_set(key, value) | Atomic swap; returns the old value. |
KEYS / SCAN | keys(pattern) | A lazy iterator with SQLite GLOB patterns (*, ?, […]). No cursor or COUNT; just stop iterating. |
FLUSHDB | flush() | Clears the current namespace only. There's no FLUSHALL: flush each namespace, or delete the file. |
SELECT | the namespace option | A namespace is a name, not a number. A separate file isolates more strongly. |
HSET / HGET / HGETALL / HINCRBY | hset / hget / hgetall / hincrby | A hash is a JSON object value, so get returns the whole document. Field names are top-level keys only. Also hsetnx, hset_many, hmget, hdel, hexists, hkeys, hvals, hlen. |
PUBLISH / SUBSCRIBE / PSUBSCRIBE | notify(channel, payload) / watch(pattern) | One host only, polled (about 50 ms), durable, at-least-once. A pattern is an exact name or a trailing *. Subscribers can resume from a saved seq, which Redis Pub/Sub can't. |
INFO | stats() | Key count and size come from the file. Hits and misses are per process. |
WATCH / MULTI / EXEC | no equivalent | Use an atomic method (add, replace, get_set, incr, the hash operations) or wrap the sequence in lock(). |
What has no equivalent
It's better to find this out now than on day three. If any of these is central to what you're building, keep Redis.
| Redis feature | What to do instead |
|---|---|
| Clustering and sharding | There's one file on one machine. If one machine's cache isn't enough, use Redis or Valkey. |
| Replication and failover | None. A cache is meant to be disposable: if the file is lost, it refills from the source. If you need a replica, you need a server. |
| Pub/sub across machines | Events stay on one host. Use Redis, NATS or a message broker. |
Lua scripting, EVAL, functions | Use the atomic methods, or put the sequence inside lock(). |
| Streams with consumer groups | watch fans out to every subscriber and has no exclusive delivery. For work queues use a real queue (or Redis Streams, Kafka, NATS JetStream). |
Lists and blocking pops (LPUSH, BRPOP) | Not implemented. For a queue, use a queue. For a small list, store it as one JSON value. |
Sets (SADD, SINTER) | Not implemented. For a small set, use a hash with members as field names. |
| Sorted sets | Not available yet. A database table with an index does leaderboards and time-ordered data well. |
| Geospatial, HyperLogLog, bitmaps | Not implemented. Use PostGIS or SpatiaLite for geo; an exact count in your database usually beats an estimate at this scale. |
| LFU eviction | Not available yet. LRU, TTL-first, random and no-eviction are. |
Guides
Framework integration
Laravel has a real cache driver. The other frameworks below don't have an adapter yet, so these sections show how to wire the plain library in by hand. Each snippet is for one framework, so the language toggle doesn't apply to them.
Spring Boot
No adapter yet. There's no Spring CacheManager for lytecache, so @Cacheable won't use it. Register one LyteCache as a singleton bean and inject it. One instance per application is right. It's thread-safe, and each extra instance on the same file only adds connections and another sweeper thread.
@Configuration
public class CacheConfig {
@Bean(destroyMethod = "close")
public LyteCache lyteCache(@Value("${app.cache-path}") Path path) {
return LyteCache.builder().path(path).namespace("orders").build();
}
}
@Service
public class RatesService {
private final LyteCache cache;
private final RatesClient client;
public RatesService(LyteCache cache, RatesClient client) {
this.cache = cache;
this.client = client;
}
public Rates rates(String base) {
return cache.memoize("rates:" + base, Duration.ofMinutes(5), () -> client.fetch(base));
}
}
destroyMethod = "close" stops the sweeper and flushes buffered state when the context shuts down. Set app.cache-path to a directory on a volume in production; see Troubleshooting for what happens if you don't.
Django
No adapter yet. There's no CACHES backend, so Django's own cache framework won't use lytecache. Create the cache in a module and import it where you need it.
# myproject/cache.py
from django.conf import settings
from lytecache import LyteCache
cache = LyteCache(settings.BASE_DIR / "var" / "cache.db", namespace="myproject")
# anywhere
from myproject.cache import cache
def product_view(request, pk):
product = cache.get(f"product:{pk}")
if product is None:
product = serialize(Product.objects.get(pk=pk))
cache.set(f"product:{pk}", product, ttl=600)
return JsonResponse(product)
Create the cache in each worker, not before Gunicorn forks. The library opens its SQLite connection when you construct it and doesn't detect a fork(), so a cache built in the master process under --preload would hand every worker a copy of the same connection. Without --preload, each worker imports the module itself and gets its own.
Flask
No adapter yet. Attach one cache to the app and close it at exit.
import atexit
from flask import Flask, current_app
from lytecache import LyteCache
def create_app():
app = Flask(__name__)
app.extensions["lytecache"] = cache = LyteCache(app.instance_path + "/cache.db")
atexit.register(cache.close)
@app.get("/rates/<base>")
def rates(base):
cache = current_app.extensions["lytecache"]
return cache.get(f"rates:{base}") or refresh_rates(base)
return app
The same fork rule applies as for Django: build the cache inside create_app() in each worker, not at import time in a preloaded master.
Express
No adapter yet. Export one cache from a module, use it in routes or middleware, and close it on shutdown.
// cache.ts
import { LyteCache } from "lytecache";
export const cache = new LyteCache({ path: process.env.LYTECACHE_PATH ?? "./var/cache.db" });
// app.ts
import express from "express";
import { cache } from "./cache.js";
const app = express();
app.get("/rates/:base", async (req, res) => {
const rates = await cache.memoizeAsync(`rates:${req.params.base}`, 300,
() => fetchRates(req.params.base));
res.json(rates);
});
const server = app.listen(3000);
process.on("SIGTERM", () => server.close(() => cache.close()));
Under the cluster module or PM2, each worker process opens its own handle to the same file. That's the intended setup, and they all see each other's writes.
Laravel
A real driver ships with the PHP package. The service provider is auto-discovered, so installing the package and setting one environment variable is the whole setup. Cache::remember(), Cache::lock() and the rest of the facade then use lytecache.
composer require lytecache/lytecache
echo "CACHE_STORE=lytecache" >> .env
use Illuminate\Support\Facades\Cache;
$user = Cache::remember("user:42", 300, fn () => User::find(42));
Cache::lock("import:products", 60)->block(30, function () {
// one worker at a time, across every PHP-FPM process on this host
});
The file defaults to storage_path('framework/cache/lytecache.db'). To change it, or set a namespace, cap or eviction policy, publish the config with php artisan vendor:publish --tag=lytecache-config. PHP has no background thread, so schedule the maintenance command to keep a quiet cache swept:
// routes/console.php
Schedule::command("lytecache:maintain")->everyMinute();
Guides
Troubleshooting
Find the symptom you're seeing. Each entry gives one way to confirm the cause, then the fix.
The habit that prevents most of these
Log the exact key at the place you write it and at the place you read it, and compare the two. Most "the cache doesn't work" reports turn out to be two different keys.
“My key is there when I write it and gone when I read it.”
Work down this list in order. The first check finds the problem most of the time.
- Compare the key strings character by character. Print them with quotes around them, at both sites. The usual culprit is something interpolated into the key:
user:Noneoruser:undefinedbecause an ID wasn't loaded yet,42at one site and"42 "at the other, or a UUID in upper case in one place and lower case in another. - Check the TTL right after the write. If
ttl(key)prints0.3when you meant five minutes, the key really did expire. See the units entry at the end of this section. - Check
exists(key)at the read site. If it's false while the writer's own check is true, the two sites aren't looking at the same file or namespace. The next entry covers that.
print(repr(key), cache.ttl(key)) # at the write site, straight after set()
print(repr(key), cache.exists(key)) # at the read site
“stats shows zero keys, but my app is definitely caching.”
Almost always a namespace mismatch. Your app opened the cache with a namespace, and the CLI looks in default unless you tell it otherwise. Both are reading the right file, just different partitions of it. Nothing errors. The CLI just sees an empty namespace, which makes this the most confusing failure in the product.
Look at how your app opens the cache, then pass the same namespace to the CLI:
| In your app | On the command line |
|---|---|
LyteCache(namespace="sessions") | lytecache --namespace sessions stats |
.namespace("sessions") (Java) | |
new LyteCache({ namespace: "sessions" }) | |
lytecache.WithNamespace("sessions") | |
new LyteCache(namespace: "sessions") |
To see every namespace in a file and how many keys each holds, open it in the Admin UI, whose sidebar lists them with counts. Or ask SQLite directly:
sqlite3 "$(lytecache which | head -1)" "SELECT namespace, COUNT(*) FROM cache GROUP BY namespace"
Two more things that look like this problem but aren't. A LYTECACHE_NAMESPACE environment variable does nothing, because no implementation reads one; set the namespace in code. And the hits and misses lines in lytecache stats always read 0. They count the CLI's own reads, not your app's. The line that tells you whether you're looking at the right namespace is keys.
“The cache is empty after every deploy or restart.”
The file is living in the container's writable layer, which is thrown away whenever the container is recreated. Confirm it by looking for a volume at the cache path: if docker inspect shows nothing mounted there, that's the cause.
Mount a named volume on the cache directory and point LYTECACHE_PATH into it:
services:
app:
environment:
LYTECACHE_PATH: /var/cache/lytecache/app.db
volumes:
- lytecache-data:/var/cache/lytecache
volumes:
lytecache-data:
Mount the directory, not the .db file, because SQLite creates app.db-wal and app.db-shm next to it.
“SQLITE_CANTOPEN / unable to open database file”
The cache directory doesn't exist, or it exists but is owned by root while your app runs as a non-root user. Confirm it from inside the container with ls -ld /var/cache/lytecache and id.
Create the directory and hand it to your app's user in the Dockerfile, before the USER line:
RUN mkdir -p /var/cache/lytecache && chown app:app /var/cache/lytecache
USER app
When Docker mounts an empty named volume for the first time, it copies that directory's ownership from the image. So getting it right in the image fixes the volume too. A volume that was already created with root ownership keeps it, though. Fix that one with a one-off chown, or remove the volume and let it be recreated.
“The CLI says the database file doesn't exist, and Docker logs Volume … Created.”
You're running the CLI in a different Compose project from your app. Compose names volumes after the project (myapp_lytecache-data), and it takes the project name from the directory you run it in unless something sets it explicitly. Run the CLI service from another directory and Compose looks for otherdir_lytecache-data, doesn't find it, and creates a new, empty one.
Compose creating a volume or network when you only meant to run the CLI is the tell. It means you're not in the app's project. Check which projects exist and run the CLI in the right one:
docker compose ls # the real project names
docker compose -p myapp run --rm lytecache-cli stats # run inside the app's project
docker volume rm otherdir_lytecache-data # tidy up the stray volume
“The Admin UI starts, but the dashboard is empty.”
The UI container mounts a different volume from the one your app writes to. The container starts fine either way, so nothing tells you. Compare the mounts of the two containers:
docker inspect myapp-app-1 --format '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{println}}{{end}}'
docker inspect myapp-lytecache-ui-1 --format '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{println}}{{end}}'
The volume names must match. If they don't, mount the app's volume in the UI service. Multiple app services, one Admin UI covers setups with several services.
“Writes seem to be getting lost under load.”
SQLite allows one writer at a time. A process that finds the file locked waits up to five seconds (busy_timeout=5000) for its turn. If it still can't write, it gets a "database is locked" error. It doesn't silently drop the write. So a lost write means either an error that something swallowed, or a bug.
Look for a try or catch around cache writes that discards the error. While you investigate, turn on strict mode too: by default a failing read is logged and returned as a miss, which can look like a write that never happened. Read-modify-write code (get, change, set) loses updates whenever two writers overlap. Use incr, the hash operations or lock() instead.
If you've ruled those out, please open an issue and include the library and version (pip show lytecache, npm ls lytecache, the Maven/Composer version or the Go module version), the SQLite version, how many processes write concurrently, and whether the file sits on a network filesystem. NFS and Kubernetes ReadWriteMany volumes aren't supported, because SQLite's locking doesn't work reliably on them.
“PHP: installed fine, but crashes with a fatal error.”
lytecache for PHP needs PHP 8.2 or newer, and Composer refuses to install it on anything older. If the install went through anyway, something bypassed that check, and the library now stops at startup with this message instead of crashing mid-request:
RuntimeException: lytecache requires PHP 8.2 or newer; this process is running PHP 8.1.34.
Composer should have refused this install, so it was most likely installed with
--ignore-platform-reqs or --ignore-platform-req=php, or the root composer.json sets
config.platform.php.
Check the three ways a bypass happens:
- An install flag. Look for
--ignore-platform-reqsor--ignore-platform-req=phpin your Dockerfile, CI workflow or deploy script, and remove it. If one build genuinely needs it, narrow it to the one extension involved (--ignore-platform-req=ext-something). config.platform.phpin your app'scomposer.json. It makes Composer resolve for a PHP you aren't running.composer config platform.phpprints it if it's set. Composer's own startup check usually reports this one first, as “Composer detected issues in your platform”.- A
composer.lockresolved on a different PHP. Runcomposer check-platform-reqson the machine that runs the app. It lists every requirement this PHP doesn't meet.
The fix is a supported PHP. Don't silence the error: below 8.2 the library can't run.
“My keys expire sooner or later than I expected.”
In Python, Node.js and PHP, ttl is in seconds. Passing milliseconds out of habit, like ttl=5000, gives you 83 minutes instead of 5 seconds. Going the other way, ttl=0.3 is 300 milliseconds. Java and Go take a typed duration, so they can't get this wrong.
Check what you actually stored straight after the write:
cache.set("otp:42", code, ttl=5000)
cache.ttl("otp:42") # 4999.9 -> you meant ttl=5
If the TTL is right but the row is still in the file after it expired, that's the sweep interval. Reads already treat it as gone; see Lazy and active expiration.
Guides
TTL & expiration
TTLs work the same way for every value type: strings, numbers, dataclasses, dicts, POJOs, records. Storing an object changes nothing.
Watch the units
Python's, Node.js's, and PHP's ttl are all plain numbers, in seconds (a float in Python and PHP, a number in Node). ttl=5000 / { ttl: 5000 } means "expire in 5000 seconds" (~83 minutes), not 5 seconds. That's the most common reason a key seems to "never expire." Java's and Go's are explicit (a Java Duration, a Go time.Duration), so there's no unit ambiguity there.
cache.set("session", {"user_id": 42}, ttl=300) # expires in 300s (5 min)
cache.set("otp", "123456", ttl=0.5) # expires in 500ms
cache.ttl("session") # seconds remaining, e.g. 298.7
cache.ttl("permanent_key") # -1 if no TTL was set
cache.ttl("missing") # None if the key doesn't exist
cache.expire("session", 60) # overwrite the TTL: now expires in 60s
cache.persist("session") # remove the TTL entirely
cache.touch("session", 300) # sliding expiration: refresh back to 300s
cache.set("session", "42", Duration.ofMinutes(5));
cache.set("otp", "123456", Duration.ofMillis(500));
cache.ttl("session"); // Duration remaining
cache.ttl("permanent"); // Duration.ofSeconds(-1) if no TTL
cache.ttl("missing"); // null if the key doesn't exist
cache.expire("session", Duration.ofSeconds(60)); // overwrite the TTL
cache.persist("session"); // remove the TTL entirely
cache.touch("session", Duration.ofMinutes(5)); // sliding expiration
cache.set("session", { userId: 42 }, { ttl: 300 }); // expires in 300s (5 min)
cache.set("otp", "123456", { ttl: 0.5 }); // expires in 500ms
cache.ttl("session"); // seconds remaining, e.g. 298.7
cache.ttl("permanentKey"); // -1 if no TTL was set
cache.ttl("missing"); // undefined if the key doesn't exist
cache.expire("session", 60); // overwrite the TTL: now expires in 60s
cache.persist("session"); // remove the TTL entirely
cache.touch("session", 300); // sliding expiration: refresh back to 300s
cache.Set("session", 42, lytecache.TTL(5*time.Minute)) // expires in 5 min
cache.Set("otp", "123456", lytecache.TTL(500*time.Millisecond))
_, hasExpiry, found, _ := cache.TTLOf("session") // remaining time.Duration
_, hasExpiry, found, _ = cache.TTLOf("permanent") // hasExpiry=false if no TTL
_, _, found, _ = cache.TTLOf("missing") // found=false if the key doesn't exist
cache.Expire("session", 60*time.Second) // overwrite the TTL
cache.Persist("session") // remove the TTL entirely
cache.Touch("session", 5*time.Minute) // sliding expiration: refresh back to 5 min
$cache->set("session", ["user_id" => 42], ttl: 300); // expires in 300s (5 min)
$cache->set("otp", "123456", ttl: 0.5); // expires in 500ms
$cache->ttl("session"); // seconds remaining, e.g. 298.7
$cache->ttl("permanent_key"); // -1 if no TTL was set
$cache->ttl("missing"); // null if the key doesn't exist
$cache->expire("session", 60); // overwrite the TTL: now expires in 60s
$cache->persist("session"); // remove the TTL entirely
$cache->touch("session", 300); // sliding expiration: refresh back to 300s
Lazy and active expiration
Every implementation expires keys in two ways:
- Lazily, on every read (
get/exists/getAll/getMany/TTLOf): the read treats an expired row as a miss and deletes it on the spot, even with the background sweeper disabled. - Actively, by a background sweeper (a thread in Python/Java, a
setIntervaltimer in Node.js, a goroutine in Go) that runs everysweep_interval/sweepInterval/WithSweepInterval(default 60s). It deletes expired rows in batches, so dead keys don't hold disk space even if nothing reads them again. PHP has no background threads. Instead it runs short maintenance passes opportunistically (roughly every 100 operations, throttled bysweepInterval). In a Laravel app, also schedule thelytecache:maintainartisan command so a quiet cache still gets swept.
Open the file in a SQLite browser right after a key expires and you may still see the row. The sweeper hasn't reached it yet. get() and exists() already treat it as gone. To remove expired rows right away, lower the sweep interval or run lytecache maintain (Maintain() in Go, maintain() in PHP). To shrink the file afterwards, call vacuum().
Guides
Flush & delete
cache.delete("key1", "key2") # delete specific keys; returns how many existed
cache.flush() # delete EVERYTHING in the current namespace
cache.delete("key1", "key2"); // delete specific keys; returns how many existed
cache.flush(); // delete EVERYTHING in the current namespace
cache.delete("key1", "key2"); // delete specific keys; returns how many existed
cache.flush(); // delete EVERYTHING in the current namespace
cache.Delete("key1", "key2") // delete specific keys; returns how many existed
cache.Flush() // delete EVERYTHING in the current namespace
$cache->delete("key1", "key2"); // delete specific keys; returns how many existed
$cache->flush(); // delete EVERYTHING in the current namespace
flush() always clears the whole namespace: default, unless you configured another. It takes no key or pattern. To clear part of a namespace, delete by key or by pattern:
for key in list(cache.keys("session:*")):
cache.delete(key)
cache.keys("session:*").forEach(cache::delete);
for (const key of cache.keys("session:*")) cache.delete(key);
for key, _ := range cache.Keys("session:*") {
cache.Delete(key)
}
foreach ($cache->keys("session:*") as $key) {
$cache->delete($key);
}
Namespaces
Two LyteCache instances on the same file but different namespaces are fully isolated. flush() on one never touches the other's keys. That's the usual reason flush() seems not to delete everything: you're flushing a different namespace from the one you wrote to.
Reference
Configuration
Every option has a sane default; nothing here is required to get started.
LyteCache(
path=None, # explicit file path; default: LyteCache.default_path()
namespace="default", # logical partition within the database file
max_keys=None, # evict when the namespace exceeds this many keys
max_bytes=None, # evict when the namespace exceeds this many bytes
eviction="lru", # "lru" | "ttl" | "random" | "noeviction"
sweep_interval=60.0, # seconds between maintenance passes; None disables
# the thread and sweeps opportunistically instead
serializer="auto", # "auto" | "json" (strict) | "pickle"
strict=False, # True: raise on internal read errors
allow_pickle=False, # "auto" mode only: allow reading pickled values
)
LyteCache cache = LyteCache.builder()
.path(Path.of("/data/my-cache.db")) // optional; platform cache dir if omitted
.namespace("sessions") // isolate multiple caches in one file
.maxKeys(100_000) // evict when exceeded (default: 1M)
.maxBytes(256L * 1024 * 1024) // evict when exceeded (default: 1 GB)
.eviction(Eviction.LRU) // LRU (default), TTL, RANDOM, NOEVICTION
.sweepInterval(Duration.ofSeconds(60)) // background cleanup frequency
.strict(false) // on read error: log+miss, or throw
.build();
new LyteCache({
path: undefined, // explicit file path; default: LyteCache.defaultPath()
namespace: "default", // logical partition within the database file
maxKeys: undefined, // evict when the namespace exceeds this many keys
maxBytes: undefined, // evict when the namespace exceeds this many bytes
eviction: "lru", // "lru" | "ttl" | "random" | "noeviction"
sweepInterval: 60, // seconds between maintenance passes; null disables
// the timer and sweeps opportunistically instead
strict: false, // true: throw on internal read errors instead of
// degrading to a miss
logger: console.warn, // called on a non-strict degraded read
});
cache, err := lytecache.New(
lytecache.WithPath("/data/cache.db"), // optional; default: lytecache.DefaultPath()
lytecache.WithNamespace("sessions"), // isolate multiple caches in one file
lytecache.WithMaxKeys(100_000), // evict when exceeded (default: no limit)
lytecache.WithMaxBytes(256<<20), // evict when exceeded (default: no limit)
lytecache.WithEviction(lytecache.LRU), // LRU (default), TTLPolicy, Random, NoEviction
lytecache.WithSweepInterval(60*time.Second), // 0 disables the goroutine -> opportunistic mode
lytecache.WithStrict(false), // true: return read errors instead of a miss
)
new LyteCache(
path: null, // explicit file path; default: LyteCache::defaultPath()
namespace: "default", // logical partition within the database file
maxKeys: null, // evict when the namespace exceeds this many keys
maxBytes: null, // evict when the namespace exceeds this many bytes
eviction: Eviction::LRU, // LRU (default), TTL, Random, NoEviction
sweepInterval: 60.0, // minimum seconds between opportunistic maintenance passes
strict: false, // true: throw on internal read errors instead of a miss
);
Eviction policies: lru (default, evicts least-recently-used), ttl (soonest-to-expire first), random, and noeviction (rejects the write instead of evicting). LFU is not available yet in any implementation. The glossary expands LRU and the other abbreviations.
One default differs between languages. Java caps a cache at 1,000,000 keys and 1 GB unless you say otherwise. The other four languages have no cap until you set max_keys or max_bytes.
Laravel
You don't need any of this to use lytecache from Laravel. Set CACHE_STORE=lytecache and you're done. The options above are still available, under stores.lytecache in config/cache.php. Publish the stub with php artisan vendor:publish --tag=lytecache-config. There, path defaults to storage_path('framework/cache/lytecache.db') rather than the platform cache directory.
Reference
Method reference
Grouped by what you're trying to do. Every group shows all five languages. Pick one on the toggle and the whole page follows.
Set & get
cache.set("key", "value")
cache.set("key", "value", ttl=300)
cache.get("key") # None if missing or expired
cache.get("key", "default") # custom default on miss
cache.delete("key1", "key2") # returns count actually deleted
cache.exists("key")
cache.set("key", "value");
cache.set("key", "value", Duration.ofMinutes(5));
String val = cache.getString("key"); // null if missing or expired
Long n = cache.getLong("key");
Double d = cache.getDouble("key");
byte[] b = cache.getBytes("key");
cache.delete("key1", "key2"); // returns count actually deleted
cache.exists("key");
cache.set("key", "value");
cache.set("key", "value", { ttl: 300 });
cache.get("key"); // undefined if missing or expired
cache.get("key", "default"); // custom default on miss
cache.delete("key1", "key2"); // returns count actually deleted
cache.exists("key");
cache.Set("key", "value")
cache.Set("key", "value", lytecache.TTL(5*time.Minute))
var v string
found, _ := cache.Get("key", &v) // found=false if missing or expired
s, found, _ := cache.GetString("key") // typed convenience wrapper
cache.Delete("key1", "key2") // returns count actually deleted
cache.Exists("key")
$cache->set("key", "value");
$cache->set("key", "value", ttl: 300);
$cache->get("key"); // null if missing or expired
$cache->get("key", "default"); // custom default on miss
$cache->delete("key1", "key2"); // returns count actually deleted
$cache->has("key");
Objects & generics
Anything JSON can represent round-trips as portable JSON that every language can read: dicts, lists, dataclasses, records, and POJOs (plain Java objects; see the glossary).
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
cache.set("p:1", Person("Samson", 30))
cache.get("p:1") # plain dict by default
cache.get("p:1", cls=Person) # typed: Person(name="Samson", age=30)
record Person(String name, int age) {}
cache.set("p:1", new Person("Samson", 30));
cache.get("p:1", Person.class);
// Generic types a raw Class can't express:
Map<String, Long> scores = cache.get("scores",
new TypeReference<Map<String, Long>>() {});
class Person {
constructor(public name: string, public age: number) {}
}
cache.set("p:1", new Person("Samson", 30));
cache.get("p:1"); // plain object by default
cache.get("p:1", undefined, { into: Person }); // typed: instanceof Person
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
cache.Set("p:1", Person{Name: "Samson", Age: 30})
var p Person
found, _ := cache.Get("p:1", &p) // typed: struct fields via encoding/json tags
final class Person {
public function __construct(
public string $name,
public int $age,
) {}
}
$cache->set("p:1", new Person("Samson", 30));
$cache->get("p:1"); // plain array by default
$cache->get("p:1", class: Person::class); // typed: Person(name: "Samson", age: 30)
Add / replace / atomic swap
"NX" and "XX" below are the classic Redis flag names for these two semantics; see the glossary.
cache.add("key", "value", ttl=60) # set only if absent (SET NX)
cache.replace("key", "value", ttl=60) # set only if present (SET XX)
cache.get_set("key", "new") # atomic swap, returns the old value
cache.add("key", "value", ttl); // set only if absent (SET NX)
cache.replace("key", "value", ttl); // set only if present (SET XX)
cache.getSet("key", "new"); // atomic swap, returns the old value
cache.add("key", "value", { ttl: 60 }); // set only if absent (SET NX)
cache.replace("key", "value", { ttl: 60 }); // set only if present (SET XX)
cache.getSet("key", "new"); // atomic swap, returns the old value
cache.Add("key", "value", lytecache.TTL(60*time.Second)) // set only if absent (SET NX)
cache.Replace("key", "value", lytecache.TTL(60*time.Second)) // set only if present (SET XX)
var old string
cache.GetSet("key", "new", &old) // atomic swap, decodes the old value into old
$cache->add("key", "value", ttl: 60); // set only if absent (SET NX)
$cache->replace("key", "value", ttl: 60); // set only if present (SET XX)
$cache->getSet("key", "new"); // atomic swap, returns the old value
Batch operations
cache.set_many({"a": "1", "b": "2"}, ttl=60) # single transaction
cache.get_many(["a", "b", "missing"]) # {"a": "1", "b": "2"}
cache.setAll(Map.of("a", "1", "b", "2"), ttl); // single transaction
cache.getAll(List.of("a", "b", "missing")); // {a=1, b=2}
cache.setMany({ a: "1", b: "2" }, { ttl: 60 }); // single transaction
cache.getMany(["a", "b", "missing"]); // Map(2) {'a' => '1', 'b' => '2'}
cache.SetMany(map[string]any{"a": "1", "b": "2"}, lytecache.TTL(60*time.Second))
results, _ := cache.GetMany([]string{"a", "b", "missing"}) // map[string]RawValue, decode per key
$cache->setMany(["a" => "1", "b" => "2"], ttl: 60); // single transaction
$cache->getMany(["a", "b", "missing"]); // ["a" => "1", "b" => "2"]
Atomic counters
Each call is one SQL UPSERT, a statement that inserts the row or updates it in place (see the glossary). So counters stay correct when many threads or processes increment at once: there's no gap between reading and writing for another writer to slip into. incr keeps a key's existing TTL. A counter it creates has no TTL, and one whose TTL has passed starts again from zero with no TTL.
cache.incr("hits") # +1
cache.incr("hits", 5) # +5
cache.decr("hits") # -1
cache.incr_float("ratio", 0.5)
cache.incr("hits"); // +1
cache.incr("hits", 5); // +5
cache.decr("hits"); // -1
cache.incrDouble("ratio", 0.5);
cache.incr("hits"); // +1
cache.incr("hits", 5); // +5
cache.decr("hits"); // -1
cache.incrFloat("ratio", 0.5);
cache.Incr("hits", 1) // +1
cache.Incr("hits", 5) // +5
cache.Decr("hits", 1) // -1
cache.IncrFloat("ratio", 0.5)
$cache->incr("hits"); // +1
$cache->incr("hits", 5); // +5
$cache->decr("hits"); // -1
$cache->incrFloat("ratio", 0.5);
Key scanning
for key in cache.keys("session:*"): # lazy iterator, GLOB syntax
print(key)
cache.keys("session:*") // lazy Stream<String>, GLOB syntax
.forEach(System.out::println);
for (const key of cache.keys("session:*")) { // lazy generator, GLOB syntax
console.log(key);
}
for key, err := range cache.Keys("session:*") { // iter.Seq2, GLOB syntax
fmt.Println(key, err)
}
foreach ($cache->keys("session:*") as $key) { // lazy Generator, GLOB syntax
echo $key;
}
Stats & maintenance
cache.stats()
# {'hits': 42, 'misses': 3, 'hit_rate': 0.93, 'key_count': 12,
# 'size_bytes': 8291, 'evictions': 0, 'path': '...'}
cache.vacuum() # reclaim disk space
cache.close() # shut down cleanly
CacheStats stats = cache.stats();
stats.hitRate(); // percentage
stats.keyCount();
stats.sizeBytes();
stats.path();
cache.vacuum(); // reclaim disk space
cache.close(); // shut down cleanly (or use try-with-resources)
cache.stats();
// { hits: 42, misses: 3, hitRate: 0.93, keyCount: 12,
// sizeBytes: 8291, evictions: 0, path: '...' }
cache.vacuum(); // reclaim disk space
cache.close(); // shut down cleanly (or use Symbol.dispose)
stats, _ := cache.Stats()
// {Hits:42 Misses:3 HitRate:0.93 KeyCount:12 SizeBytes:8291 Evictions:0 Path:"..."}
cache.Vacuum() // reclaim disk space
cache.Close() // shut down cleanly (idempotent)
$cache->stats();
// CacheStats(hits: 42, misses: 3, hitRate: 0.93, keyCount: 12,
// sizeBytes: 8291, evictions: 0, path: "...")
$cache->vacuum(); // reclaim disk space
$cache->close(); // shut down cleanly (idempotent; __destruct() also calls it)
One unit differs: Java's hitRate() is a percentage from 0 to 100, while the other languages return a fraction from 0 to 1.
Distributed locks
Locks are built on the same atomic add() as everything else, so only one holder can have a given lock name at a time, across threads and processes on this host. The timeout does two jobs. It's how long lock() waits to acquire, and it's the lock's own TTL, so a holder that crashes can't wedge the lock forever. Pick a timeout longer than the work takes: if the work runs past it, the lock expires and another worker can take it.
with cache.lock("resource", timeout=30):
do_critical_work()
try (CacheLock lock = cache.lock("resource", Duration.ofSeconds(30))) {
doCriticalWork();
}
using lock = cache.lock("resource", { timeoutMs: 30_000 });
doCriticalWork();
lock, err := cache.Lock("resource", 30*time.Second)
defer lock.Release()
doCriticalWork()
$cache->lock("resource", timeout: 30.0)->block(function () {
doCriticalWork();
});
Read-through memoization
@cache.memoize(ttl=3600)
def expensive(x):
return compute(x)
String result = cache.memoize("expensive:" + x, Duration.ofHours(1),
() -> compute(x));
const result = cache.memoize(`expensive:${x}`, 3600, () => compute(x));
result, err := lytecache.Memoize(cache, fmt.Sprintf("expensive:%v", x), time.Hour,
func() (T, error) { return compute(x) })
$result = $cache->remember("expensive:{$x}", 3600, fn () => compute($x));
Hashes (JSON fields)
Read and change single fields of a stored JSON object, the way Redis HSET does. Each write is computed inside SQLite from the row's current value. Two workers can update different fields of one key, or increment the same field, and neither loses the other's change, whether they're threads or separate processes. The obvious get(), modify, set() sequence silently loses updates.
cache.hset("user:101", "status", "active") # True: the field was new
cache.hset("user:101", "status", "paused") # False: it replaced a value
cache.hincrby("user:101", "logins") # 1 -- an atomic counter inside the document
cache.hget("user:101", "status") # "paused"
cache.hmget("user:101", ["status", "plan"]) # ["paused", None]
cache.hgetall("user:101") # {"status": "paused", "logins": 1}
cache.hdel("user:101", "plan") # 0 -- it wasn't there
cache.get("user:101") # the whole document: a hash is just JSON
cache.hset("user:101", "status", "active"); // true: the field was new
cache.hset("user:101", "status", "paused"); // false: it replaced a value
cache.hincrBy("user:101", "logins", 1); // 1 -- an atomic counter inside the document
String status = cache.hgetString("user:101", "status"); // "paused"
List<Object> some = cache.hmget("user:101", List.of("status", "plan")); // ["paused", null]
Map<String, Object> all = cache.hgetAll("user:101"); // {status=paused, logins=1}
cache.hdel("user:101", "plan"); // 0 -- it wasn't there
cache.hset("user:101", "status", "active"); // true: the field was new
cache.hset("user:101", "status", "paused"); // false: it replaced a value
cache.hincrby("user:101", "logins"); // 1 -- an atomic counter inside the document
cache.hget("user:101", "status"); // "paused"
cache.hmget("user:101", ["status", "plan"]); // ["paused", undefined]
cache.hgetAll("user:101"); // { status: "paused", logins: 1 }
cache.hdel("user:101", "plan"); // 0 -- it wasn't there
created, err := cache.HSet("user:101", "status", "active") // true: the field was new
logins, err := cache.HIncrBy("user:101", "logins", 1) // 1 -- an atomic counter inside the document
status, found, err := cache.HGetString("user:101", "status")
var plan Plan
found, err = cache.HGet("user:101", "plan", &plan) // decode one field into any type
vals, err := cache.HMGet("user:101", []string{"status", "plan"}) // []json.RawMessage, nil if missing
all, err := cache.HGetAll("user:101") // map[string]json.RawMessage
n, err := cache.HDel("user:101", "plan")
$cache->hset("user:101", "status", "active"); // true: the field was new
$cache->hset("user:101", "status", "paused"); // false: it replaced a value
$cache->hincrBy("user:101", "logins"); // 1 -- an atomic counter inside the document
$cache->hget("user:101", "status"); // "paused"
$cache->hmget("user:101", ["status", "plan"]); // ["paused", null]
$cache->hgetAll("user:101"); // ["status" => "paused", "logins" => 1]
$cache->hdel("user:101", "plan"); // 0 -- it wasn't there
A hash isn't a separate type. Anything stored with set(key, {...}) works with hget, and get returns the whole document. A field write on a missing key creates it with no expiry, unless you pass a TTL, and that TTL only applies on creation. A field write never changes an existing TTL and never revives an expired key. Field names are literal top-level keys: "a.b" is one field, not a path. A hash operation on a value that isn't a JSON object raises the library's serialization error and leaves the value alone. stats() reports which mode hashes run in (hash_mode / hashMode / HashMode). Native mode needs SQLite 3.45 or newer, and 3.48 for a field name containing "; on 3.45–3.47 only calls naming such a field use the fallback. Older SQLite runs every hash call through the fallback. You'll mostly meet that with PHP, whose pdo_sqlite uses the system SQLite: Debian 12 ships 3.40.1, Debian 13 ships 3.46.1. Results are identical either way, but the fallback re-reads the whole document, so its cost grows with the hash's size.
Local events (notify / watch)
Read this first
Notifications work between processes on one machine. A process writes an event, and other processes polling the same file pick it up. Nothing crosses a machine boundary: if your subscribers run on different hosts, you need Redis or a message broker. Delivery takes about one poll interval (50 ms, or up to about 1 s after an idle spell). It's at-least-once, so make your handlers idempotent. This is not Redis Pub/Sub. Events are rows with a monotonic seq, kept for an hour by default, so a restarted subscriber picks up where it left off.
The main use is cross-worker cache invalidation:
# worker A, after committing a DB change
cache.delete("balance:42")
cache.notify("invalidate", {"key": "balance:42"})
# workers B..N, on the same host
with cache.watch("invalidate") as sub:
for event in sub:
local_memo.pop(event.payload["key"], None)
// worker A, after committing a DB change
cache.delete("balance:42");
cache.notify("invalidate", Map.of("key", "balance:42"));
// workers B..N, on the same host
try (Subscription sub = cache.watch("invalidate")) {
for (Event e : sub) {
localMemo.remove(e.payload(Map.class).get("key"));
}
}
// worker A, after committing a DB change
cache.delete("balance:42");
cache.notify("invalidate", { key: "balance:42" });
// workers B..N, on the same host
using sub = cache.watch("invalidate");
for await (const event of sub) {
localMemo.delete(event.payload.key);
}
// worker A, after committing a DB change
cache.Delete("balance:42")
cache.Notify("invalidate", map[string]any{"key": "balance:42"})
// workers B..N, on the same host
sub, err := cache.Watch([]string{"invalidate"})
defer sub.Close()
for ev := range sub.Events() {
var p struct{ Key string `json:"key"` }
ev.Decode(&p)
delete(localMemo, p.Key)
}
// worker A, after committing a DB change
$cache->delete("balance:42");
$cache->notify("invalidate", ["key" => "balance:42"]);
// a CLI worker on the same host (PHP polls inside the foreach)
foreach ($cache->watch("invalidate") as $event) {
unset($localMemo[$event->payload()["key"]]);
}
Channels are exact names, or end in * to match a prefix ("cache:invalidate:*"); "*" matches everything. Batch many events into one transaction with notifyMany (notify_many). For durable resume, store the last seq you finished handling and start from it next time. If retention already deleted events after your cursor, you get a gap error instead of a silent skip:
from lytecache import GapError
try:
with cache.watch("jobs", from_seq=cache.load_cursor("worker-b")) as sub:
for event in sub:
handle(event) # must be idempotent
cache.save_cursor("worker-b", event.seq)
except GapError as err: # events past retention are gone
resync()
cache.save_cursor("worker-b", err.resume_seq) # accept the loss explicitly
OptionalLong cursor = cache.loadCursor("worker-b");
WatchOptions opts = cursor.isPresent()
? WatchOptions.defaults().fromSeq(cursor.getAsLong())
: WatchOptions.defaults();
try (Subscription sub = cache.watch(opts, "jobs")) {
for (Event e : sub) {
handle(e); // must be idempotent
cache.saveCursor("worker-b", e.seq());
}
} catch (GapException err) { // events past retention are gone
resync();
cache.saveCursor("worker-b", err.resumeSeq()); // accept the loss explicitly
}
import { GapError } from "lytecache";
try {
using sub = cache.watch("jobs", { fromSeq: cache.loadCursor("worker-b") });
for await (const event of sub) {
handle(event); // must be idempotent
cache.saveCursor("worker-b", event.seq);
}
} catch (err) {
if (!(err instanceof GapError)) throw err; // events past retention are gone
resync();
cache.saveCursor("worker-b", err.resumeSeq); // accept the loss explicitly
}
var opts []lytecache.WatchOption
if seq, ok, _ := cache.LoadCursor("worker-b"); ok {
opts = append(opts, lytecache.FromSeq(seq))
}
sub, err := cache.Watch([]string{"jobs"}, opts...)
defer sub.Close()
for ev := range sub.Events() {
handle(ev) // must be idempotent
cache.SaveCursor("worker-b", ev.Seq)
}
var gap *lytecache.GapError
if errors.As(sub.Err(), &gap) { // events past retention are gone
resync()
cache.SaveCursor("worker-b", gap.ResumeSeq) // accept the loss explicitly
}
use Lytecache\Exceptions\GapException;
try {
foreach ($cache->watch("jobs", fromSeq: $cache->loadCursor("worker-b")) as $event) {
handle($event); // must be idempotent
$cache->saveCursor("worker-b", $event->seq);
}
} catch (GapException $err) { // events past retention are gone
resync();
$cache->saveCursor("worker-b", $err->resumeSeq); // accept the loss explicitly
}
Retention (event_retention, default 1 hour, plus an optional count cap) sets how long a subscriber can be down without losing events. If you never call notify or watch, no events table is created and no polling thread starts. The full guide, latency numbers and the comparison with Redis are in docs/notifications.md.
Change listeners
There are two ways to react to changes in the cache. onChange (on_change) works in-process. It's immediate and doesn't touch SQLite. Listeners run only after the write commits, so a rolled-back setMany notifies nobody, and a listener that throws never fails the write. publishChanges works across processes. It writes each matching mutation to the reserved __cache__ channel, so it's polled and costs one event row per call. Either way, a bulk write produces one record, not one per key. Past 100 keys the key list is dropped and truncated is set.
def drop(rec): # a ChangeRecord: op, namespace, keys, count, truncated
if rec.truncated:
memo.clear() # a bulk change: refetch by namespace or prefix
else:
for key in rec.keys:
memo.pop(key, None)
unsubscribe = cache.on_change(drop, events={"set", "delete", "flush"})
cache.set_many(rows) # 1,000 keys -> ONE record: count=1000, truncated=True
# cross-process alternative: also publish deletes/flushes to "__cache__"
cache = LyteCache(publish_changes={"delete", "flush"})
Runnable unsubscribe = cache.onChange(rec -> {
if (rec.truncated()) {
memo.clear(); // a bulk change: refetch by namespace or prefix
} else {
rec.keys().forEach(memo::remove);
}
}, EnumSet.of(ChangeOp.SET, ChangeOp.DELETE, ChangeOp.FLUSH));
// cross-process alternative: also publish deletes/flushes to "__cache__"
LyteCache cache = LyteCache.builder().publishChanges(ChangeOp.DELETE, ChangeOp.FLUSH).build();
const unsubscribe = cache.onChange((rec) => {
if (rec.truncated) memo.clear(); // a bulk change: refetch by namespace or prefix
else for (const key of rec.keys!) memo.delete(key);
}, { events: ["set", "delete", "flush"] });
// cross-process alternative: also publish deletes/flushes to "__cache__"
const cache = new LyteCache({ publishChanges: ["delete", "flush"] });
unsubscribe, err := cache.OnChange(func(rec lytecache.ChangeRecord) {
if rec.Truncated {
clear(memo) // a bulk change: refetch by namespace or prefix
return
}
for _, k := range rec.Keys {
delete(memo, k)
}
}, lytecache.OpSet, lytecache.OpDelete, lytecache.OpFlush)
defer unsubscribe()
// cross-process alternative: also publish deletes/flushes to "__cache__"
cache, err := lytecache.New(lytecache.WithPublishChanges(lytecache.OpDelete, lytecache.OpFlush))
$unsubscribe = $cache->onChange(function (ChangeRecord $rec) use (&$memo) {
if ($rec->truncated) {
$memo = []; // a bulk change: refetch by namespace or prefix
} else {
foreach ($rec->keys as $key) {
unset($memo[$key]);
}
}
}, ["set", "delete", "flush"]);
// cross-process alternative: also publish deletes/flushes to "__cache__"
$cache = new LyteCache(publishChanges: ["delete", "flush"]);
Reference
Storage spec & type codes
Every implementation reads and writes the same schema: one SQLite file in WAL mode, with PRAGMA busy_timeout=5000 so a process that finds the file locked waits instead of failing. Every value carries a value_type code:
| Code | Type | Encoding |
|---|---|---|
| 0 | Bytes | Raw bytes, stored as-is |
| 1 | String | UTF-8 text |
| 2 | Integer | UTF-8 decimal text, not binary. That's what lets incr/decr be one atomic SQL UPSERT in every language. |
| 3 | Float | UTF-8 decimal text, same reasoning as integers |
| 4 | JSON | Any object, dict, list, dataclass, POJO, record, JS object, Go struct/map/slice, or PHP array/object/enum |
| 5 | Python pickle | Python-only escape hatch; reading it from Java, Node.js, Go, or PHP raises a clear error instead of returning garbage |
| 6 | Java serialized | Reserved, never written; reading it from Python, Node.js, Go, or PHP raises a clear error |
Node.js has a single number type rather than Python's int/float or Java's long/double, so its implementation picks type code 2 vs. 3 by shape (Number.isInteger()) rather than by caller intent, using bigint for integers beyond Number.MAX_SAFE_INTEGER. See lytecache-node/SPEC.md for the full rules.
Go, like Python and Java, has distinct integer and floating-point types, so it decides type codes by the value's actual Go type rather than by shape: any Go integer type stores as code 2, float32/float64 as code 3. A uint64 beyond what signed 64-bit integers can hold is rejected outright rather than silently truncated. See lytecache-go/SPEC.md for the full rules.
PHP strings are ambiguous between "raw bytes" and "text," unlike the other languages' distinct byte-array types, so lytecache uses a Bytes wrapper class to mean code 0 explicitly. A plain PHP string always stores as code 1. Objects serialize via JsonSerializable when implemented, else their public properties; a DateTimeInterface serializes to an RFC 3339 string and a BackedEnum to its backing value, matching the conventions above. See lytecache-php/SPEC.md for the full rules.
The zero-config default path derivation (<project-id> = SHA-256 of the resolved working directory) is byte-for-byte identical across every implementation, so all of them land on the same file for the same project directory. See Where is my data? above.
Reference
Glossary
This page uses a handful of abbreviations without expanding them inline. Here's what each one means.
| Term | Full name | What it means here |
|---|---|---|
| API | Application Programming Interface | The methods a library exposes for other code to call: here, set, get, incr and the rest. |
| CLI | Command-Line Interface | A program you run and control from a terminal, as opposed to a GUI. |
| TTL | Time To Live | How long a cached value is kept before it's treated as expired. |
| LRU | Least Recently Used | Eviction policy: when the cache is full, remove the key that hasn't been read or written in the longest time. |
| LFU | Least Frequently Used | Eviction policy that removes the key with the fewest accesses. Documented as a future addition, not yet implemented. |
| SQL | Structured Query Language | The language used to talk to SQLite. Most operations here are a single SQL statement; batch writes run several inside one transaction. |
| WAL | Write-Ahead Logging | A SQLite journaling mode that lets readers and a writer access the database file concurrently without blocking each other. |
| JSON | JavaScript Object Notation | The plain-text format used to store objects, arrays, and other structured values portably across every language. |
| POJO | Plain Old Java Object | An ordinary Java object with no special base class, interface, or framework annotation required. |
| GLOB | Global (pattern matching) | Shell-style wildcard syntax (*, ?, [...]) used by keys(pattern) to match key names. |
| NX / XX | Not eXists / already eXists | Redis-style flag names: add() behaves like SET NX (only if absent), replace() like SET XX (only if present). |
| UPSERT | Update + Insert | A single SQL statement that inserts a new row or updates the existing one, used so counters and locks stay atomic under concurrency. |
| CJS / ESM | CommonJS / ECMAScript Modules | Node.js's two module systems (require() vs. import). The Node.js package ships both. |
| SHA-256 | Secure Hash Algorithm, 256-bit | A one-way fingerprint function used to derive each project's default cache filename from its working directory. |
Tools
Command-line interface
Inspect and manipulate any lytecache database file from a shell, the way redis-cli does for a server. It's built entirely on lytecache-go's public API, so it can read and write a file created by any of the five languages above.
Install
brew install lytecache/tap/lytecache
# or, without Homebrew:
curl -fsSL https://raw.githubusercontent.com/lytecache/lytecache-cli/main/install.sh | sh
scoop bucket add lytecache https://github.com/lytecache/scoop-bucket
scoop install lytecache
go install github.com/lytecache/lytecache-cli/cmd/lytecache@latest
Prebuilt binaries, SHA-256 checksums, and .deb/.rpm packages are attached to every GitHub release; a winget manifest ships the same way, pending submission to microsoft/winget-pkgs.
Docker
The CLI is also published as a multi-arch container image (linux/amd64, linux/arm64). It's about 7 MB, built on scratch with nothing in it but the static binary, so you can inspect a cache file without installing anything. lytecache has no server mode: a CLI container runs one command and exits. (The Admin UI does serve a web page, but it isn't a cache server either.)
Mount the same volume your application uses at /var/cache/lytecache and set LYTECACHE_PATH to the value the app already uses, and the CLI needs no --db flag. It reads that variable exactly the way every library on this page does:
docker run --rm -v myapp-cache:/var/cache/lytecache \
-e LYTECACHE_PATH=/var/cache/lytecache/cache.db \
ghcr.io/lytecache/lytecache:latest stats
With Compose, add a service like this next to your app. It uses the same LYTECACHE_PATH and mounts the same named volume, which is why it needs no --db:
services:
# ... your existing app service ...
lytecache-cli:
image: ghcr.io/lytecache/lytecache:latest
profiles: ["tools"] # keeps it out of `docker compose up`
environment:
LYTECACHE_PATH: /var/cache/lytecache/cache.db # same value your app sets
volumes:
- lytecache-data:/var/cache/lytecache # same volume your app mounts
volumes:
lytecache-data:
Then docker compose run --rm lytecache-cli stats works (or keys, or no command for the REPL). run reaches a profiled service even though up skips it. Mount the volume on the directory, not on the .db file: WAL mode creates cache.db-wal and cache.db-shm next to it. You can also copy the binary into your own image. It's fully static, so any base image works:
COPY --from=ghcr.io/lytecache/lytecache:latest /lytecache /usr/local/bin/lytecache
lytecache-cli's README has the rest: the --db one-liner for any file on the host, the interactive REPL, a shell alias, and the Compose profile pattern. It also explains why sharing one .db file over NFS or a Kubernetes ReadWriteMany volume isn't supported: SQLite's locking relies on guarantees that network filesystems don't reliably provide.
A session
One-shot commands (script-friendly, shown above) and the interactive REPL share the exact same command tree. The REPL just opens the database once for the whole session instead of reopening it per command. Command names are case-insensitive there, and Ctrl-C cancels the current line without leaving.
Commands
| Command | Description |
|---|---|
get <key> | Print a value, JSON pretty-printed by default, or with --raw for exact bytes |
set <key> [value] | Set a value; type is inferred unless --type forces it |
del <key>... | Delete one or more keys; prints how many actually existed |
exists <key> | Prints 1/0; exit code matches |
ttl / expire / persist / touch | Inspect and manage a key's TTL |
incr / decr | Atomically adjust a counter; prints the new value |
keys [pattern] (alias scan) | List matching keys; --long adds type/ttl/size columns |
stats (alias info) | Key count, size, hit/miss counters, evictions, path |
flush | Delete every key in the current namespace (prompts unless --yes) |
maintain / vacuum | Run a maintenance pass / reclaim disk space |
which | Print the resolved database path and whether it exists |
dump <key> | Raw row metadata, the view you reach for when debugging |
stats --watch[=seconds] | Redraw stats every N seconds (default 2) until Ctrl-C |
hget <key> <field> / hgetall <key> | Print one field, or the whole object in document order |
hset <key> <field> <value> | Atomically set one field; prints 1 if it was new, 0 if replaced |
notify <channel> [payload] | Publish a local event; prints its seq |
watch <pattern>... [--from-seq N] | Tail events live, one JSON line per event (pipe into jq); exits 3 on a retention gap |
events [--limit 50] | Recent retained events (seq, channel, age, size) and retention stats |
Exit codes
Scripts can rely on these without parsing output:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | A read found nothing ((nil)), or a boolean result was false |
| 2 | Usage error (bad arguments/flags) |
| 3 | Database error |
Cross-language example
Since every implementation shares one on-disk format (see Storage spec above), the CLI can inspect a cache file written by any of them. A Python process wrote this:
cache.set("config", {"theme": "dark", "timeout": 30})
$ lytecache --db ~/.cache/lytecache/abc123.db get config
{
"theme": "dark",
"timeout": 30
}
The CLI didn't need Python, a server or an export step. It read the same SQLite file. Two type codes can't be decoded outside their own language: 5 (Python pickle) and 6 (Java serialization). The CLI shows them as (non-portable value: python-pickle, N bytes) instead of failing or printing garbage.
Prefer a browser?
Everything above also works in a browser. See the Admin UI.
Tools
Admin UI
A local web console for the same database files the CLI reads, like RedisInsight or pgAdmin. It isn't a cache server: it speaks no cache protocol and no application connects to it. It's lytecache-go's public API with a browser on top, started with lytecache ui --port 7070.
It ships in the same binary as the CLI, so there's nothing extra to install. If you haven't installed lytecache yet, see Install in the CLI section, or jump straight to Docker below if you'd rather not install anything locally at all.
Fleet dashboard
Every configured database side by side, with a health flag when usage nears a cap, expired keys pile up, or a subscriber loses events.
Delete-only, on purpose
The UI can delete data. It cannot create or change it. There's no route that accepts a new or edited value, so even a hand-crafted request has nothing to reach.
Events tab
A live tail of each database's notify events, retention stats, and lag for every subscriber cursor. It can't publish events. With --allow-delete it can purge them.
Prometheus /metrics
Per-database gauges for keys, size, evictions, expired-present, events and subscriber lag. A scrape never writes to the file.
Runs in the background
lytecache service install registers it with launchd, systemd or the Windows Service Control Manager, so it survives logout and starts on boot.
Install and run it, step by step
-
Install
lytecacheThe CLI and the Admin UI are one binary, so this one install covers both.
Terminalbrew install lytecache/tap/lytecacheOn Linux/Windows/from source, or want the Docker image instead? See Install in the CLI section above.
-
Point it at a database (optional)
If you know the path, name it with
--db. If you don't, skip this step. With no flags at all, the UI scans the standard cache locations for you (details below). -
Run it
Start it in the foreground first.
Ctrl+Cstops it, and nothing is left running:Terminallytecache uiTo keep it running across logouts and reboots, install it as a background service.
-
Open it and log in
Visit
http://127.0.0.1:7070and log in withadmin/admin. You land on the fleet dashboard. -
Change the password before exposing it anywhere
This only matters if you'll reach the UI from beyond
127.0.0.1, and there it's enforced: the UI refuses to bind anywhere else while the password is stilladmin. Runlytecache ui passwdto change it.
Quickstart
Open http://127.0.0.1:7070, log in with admin/admin, and you're at the fleet dashboard. Passing --allow-delete turns on delete/flush/maintain; without it, those routes return 403 whether or not the browser shows a button for them.
With no --db, --scan or config file, lytecache ui scans two standard locations. One is <platform cache dir>/lytecache, where the library puts files when your app doesn't set a path. The other is /var/cache/lytecache, the volume-mount convention used in the Docker examples. So the same zero-flag start works on a laptop and in a container. Configure any database explicitly and the scan is skipped.
Using it
Click a database row to open its key browser. The sidebar lists every namespace with its key count. A mismatched namespace is the most common reason a key seems to be missing, and this makes it hard to overlook. Click a key to open the value viewer. JSON is pretty-printed and collapsible, and every value stays hidden until you click "Reveal value". With --allow-delete you also get a delete button, bulk delete, and a flush-namespace control that makes you type the namespace name, so a stray click can't wipe anything.
/ focuses the search box from anywhere (it searches every configured database, not just the one you're in); arrow keys move through a key list, Esc clears the current selection or search.
Guardrails you can't switch off
The UI refuses to bind anything other than 127.0.0.1 while the password is still admin. If someone reaches it from another machine after the password was reset to the default, it forces a password change before showing anything else. No flag turns either check off. To reach a remote instance, we'd use an SSH tunnel (ssh -L 7070:localhost:7070 you@host) rather than expose the port at all.
Docker
The image includes the ui subcommand. Inside the container, bind 0.0.0.0: Docker can't forward a published port to a listener that only accepts loopback. Then restrict it to 127.0.0.1 on the host side:
docker run --rm -p 127.0.0.1:7070:7070 \
-v myapp-cache:/var/cache/lytecache \
-v lytecache-ui-config:/home/lytecache/.config/lytecache \
-e LYTECACHE_UI_PASSWORD=change-me \
ghcr.io/lytecache/lytecache:latest \
ui --host 0.0.0.0 --insecure --no-metrics --db orders=/var/cache/lytecache/orders.db
Mount the config volume as well as the cache volume. ui.yaml holds the credentials and the session secret. If it isn't on a volume, the password goes back to admin/admin every time the container is recreated.
With Compose, add it as a profiled service next to your app. As with the CLI service above, using the app's LYTECACHE_PATH and volume is what makes --db unnecessary:
services:
# ... your existing app service ...
lytecache-ui:
image: ghcr.io/lytecache/lytecache:latest
pull_policy: always # always check for a newer :latest before (re)creating
profiles: ["tools"] # keeps it out of `docker compose up`
command: ["ui", "--host", "0.0.0.0", "--port", "7070", "--allow-delete", "--insecure", "--no-metrics"]
environment:
LYTECACHE_PATH: /var/cache/lytecache/cache.db # same value your app sets
LYTECACHE_UI_PASSWORD: ${LYTECACHE_UI_PASSWORD:?set a real admin password first}
volumes:
- lytecache-data:/var/cache/lytecache # same volume your app mounts
- lytecache-ui-config:/home/lytecache/.config/lytecache
ports:
- "7070:7070"
volumes:
lytecache-data:
lytecache-ui-config:
Run docker compose --profile tools up -d lytecache-ui, then open http://127.0.0.1:7070. Be aware that this ports: mapping publishes on every network interface the host has, not only loopback. Anyone who can reach the host's IP can reach the UI. To keep it on the host, use "127.0.0.1:7070:7070". --insecure is only reasonable while nothing untrusted shares that network. Once the UI is reachable beyond one machine, replace it with --tls-cert/--tls-key. lytecache-cli's examples/docker-compose.yml has the full example, including a --scan fleet view across several services' caches on one volume.
Pulling a newer image
Docker doesn't fetch a newer :latest on its own. If the image is already on your machine, docker compose up keeps using it. Two ways to get the new one:
# pull, then recreate the container so it actually uses what was just pulled
docker compose pull lytecache-ui
docker compose up -d --force-recreate lytecache-ui
# or do both in one command
docker compose up -d --pull always lytecache-ui
To make that the default, add pull_policy: always to the service, as the snippet above does. Then a plain docker compose up -d lytecache-ui checks for a newer :latest first.
Multiple app services, one Admin UI
This is the most common setup mistake. lytecache-ui has to mount the same volumes your app services write to. Point it at a leftover or unrelated volume and the container still starts. It just shows an empty dashboard, because it's looking somewhere your app isn't.
Two patterns handle this correctly, trading a bit of setup for a bit of isolation:
✓ One shared volume, one file per service
- Each service gets its own
LYTECACHE_PATHfilename (admin.db,pos.db, ...) but mounts the same volume lytecache-uimounts that one volume and uses--scan, so adding a service needs no change tolytecache-ui- The setup we recommend for most fleets
✕ One shared file for everything
- Every service's writes queue behind every other service's. SQLite has one writer at a time and can't tell which callers are unrelated.
- One corrupted/locked file takes down cache access for your whole fleet at once, not just the service that caused it
- Collapses the dashboard to one row instead of one per service
- Not recommended unless you've implemented per-service namespace isolation in application code (see below) and accept the write-contention trade-off
A third option also works: a separate volume per service. Mount each one into lytecache-ui at its own subdirectory and use repeatable --db name=path flags instead of --scan. Each new service needs more wiring, but a lock or corruption problem in one file can't touch the others.
| Symptom | Cause | Fix |
|---|---|---|
| Dashboard shows "No databases configured", or a database with zero keys | lytecache-ui mounts a different volume than your app services actually write to |
Mount the same volumes your services use. Don't create a separate one for the UI. |
A LYTECACHE_NAMESPACE (or similarly-named) env var seems to do nothing |
It isn't a real setting. No implementation reads a namespace from the environment. | Isolate services by file (distinct LYTECACHE_PATH per service), or by namespace via WithNamespace(...) in actual application code |
docker compose run lytecache-cli stats always shows the same one service's data, or a file that doesn't exist |
A shared LYTECACHE_PATH env var (often via a YAML anchor reused across services) defaults it to one fixed file |
Drop the default and pass --db /path/to/service.db explicitly on each invocation instead |
service "x" refers to undefined volume "y": invalid compose project |
A service's volumes: references a name that was never declared at the top level |
Every volume name used anywhere in the file must also appear under the top-level volumes: key |
| Every app service looks migrated to the new volume layout, but the dashboard is still empty | lytecache-ui itself wasn't updated. It's easy to move every app service and forget the one that has to see all of them. |
Double-check lytecache-ui's own volumes:/command: match whatever your app services just moved to |
Running as a background service
Installed via Homebrew?
Homebrew already ran service install for you when brew install or brew upgrade finished. It only registers the service, so nothing is listening until you start it. Go straight to lytecache service start below.
lytecache service install --db orders=/data/orders.db --allow-delete
lytecache service start
lytecache service status
service install accepts every flag lytecache ui does and saves them, so the service starts the same way every time. It installs a user LaunchAgent on macOS and a user systemd unit on Linux (--system for a system-wide unit). On Windows it uses the Service Control Manager from an elevated shell, and a Scheduled Task at logon otherwise. To change the flags, for example to add a --db, run service install again. You don't need to uninstall first.
lytecache-cli's docs/ui.md covers the rest: masking sensitive values, the audit log, a paste-ready Prometheus scrape_config with alert rules, reading subscriber lag, and the full default-credential guardrails.