Metadata-Version: 2.4
Name: plain.cache
Version: 0.30.0
Summary: A simple database-backed cache for storing JSON-serializable values with optional expiration.
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
License-Expression: BSD-3-Clause
License-File: LICENSE
Requires-Python: >=3.13
Requires-Dist: plain-postgres<1.0.0,>=0.106.0
Requires-Dist: plain<1.0.0
Description-Content-Type: text/markdown

# plain.cache

**A simple database-backed cache for storing JSON-serializable values with optional expiration.**

- [Overview](#overview)
- [Setting expiration](#setting-expiration)
- [Get-or-set](#get-or-set)
- [Counters](#counters)
- [Batch operations](#batch-operations)
- [Refreshing expiration](#refreshing-expiration)
- [Checking and deleting](#checking-and-deleting)
- [Querying cached items](#querying-cached-items)
- [Automatic cleanup](#automatic-cleanup)
- [CLI commands](#cli-commands)
- [Admin integration](#admin-integration)
- [Settings](#settings)
- [FAQs](#faqs)
- [Installation](#installation)

## Overview

Import the [`cache`](./core.py#Cache) and store any JSON-serializable value under a key. Each entry can optionally expire after a set amount of time.

```python
from plain.cache import cache

# Store a value (expires in 60 seconds)
cache.set("my-cache-key", "a JSON-serializable value", expiration=60)

# Later, read it back (returns None on a miss or if expired)
value = cache.get("my-cache-key")
```

`cache` is a stateless module-level store, so there's nothing to instantiate — just import it and call. Values live in a [`CachedItem`](./models.py#CachedItem) database model, so you don't need to set up Redis or any external caching service.

Reads are **expiry-aware**: an entry past its `expires_at` reads as absent (`get()` returns the default). Expired rows are deleted out of band — see [Automatic cleanup](#automatic-cleanup).

## Setting expiration

`set()` accepts expiration as seconds, a `timedelta`, or an absolute `datetime`. Omitting it stores the value with no expiration.

```python
from datetime import datetime, timedelta
from plain.cache import cache

# Seconds as int or float
cache.set("k", "value", expiration=300)  # 5 minutes

# Timedelta
cache.set("k", "value", expiration=timedelta(hours=1))

# Specific datetime
cache.set("k", "value", expiration=datetime(2025, 12, 31, 23, 59, 59))

# No expiration (cached forever)
cache.set("k", "value")
```

`set()` always rewrites the whole entry, including its expiry. To change only the expiry without rewriting the value, use [`touch()`](#refreshing-expiration).

## Get-or-set

`get_or_set()` returns the cached value, or computes it, stores it, and returns it on a miss. `default` can be a value or a zero-arg callable (the callable runs only on a miss):

```python
from datetime import timedelta
from plain.cache import cache

data = cache.get_or_set(
    "report:42",
    lambda: build_expensive_report(42),
    expiration=timedelta(hours=1),
)
```

A stored `None` counts as a hit, so caching a computed `None` won't recompute it every time.

## Counters

`increment()` (and `decrement()`) atomically adjust a stored number in a single `INSERT ... ON CONFLICT` statement and return the new total. Because it's one statement, concurrent callers can't lose updates the way a read-then-`set()` would.

```python
from plain.cache import cache

cache.increment("page-views")          # 1 (starts at the delta on a miss)
cache.increment("page-views")          # 2
cache.increment("page-views", 10)      # 12
cache.decrement("page-views", 2)       # 10
```

A key with no numeric value yet — missing, or storing `None` — counts as `0`, so the first increment starts from the delta. Incrementing a key that holds a non-numeric value (a string, list, etc.) raises.

### Expiration

When you pass `expiration`, the counter behaves as a **fixed window**:

- A **missing or expired** key starts fresh at the delta and takes the `expiration` you pass — a lapsed window resets cleanly to a new deadline.
- A **live** key adds to the existing total and keeps its current `expires_at` — the window holds its original deadline, regardless of the `expiration` argument.

This makes a "count per period" limiter straightforward — e.g. capping requests per IP:

```python
from plain.cache import cache

def check_rate_limit(key: str, *, limit: int, period: int) -> bool:
    """Allow up to `limit` calls per `period` seconds. Returns True if allowed."""
    return cache.increment(key, expiration=period) <= limit
```

Note this is a _fixed_ window, so it permits a boundary burst (up to `limit` just before the window resets and `limit` just after). For smooth sliding-window or token-bucket limiting you need extra state this primitive doesn't carry.

For a **sliding TTL** — reset only after a stretch of inactivity, rather than on a fixed schedule — refresh the expiry on each increment with [`touch()`](#refreshing-expiration). The count stays atomic; the TTL refresh is a cheap second statement:

```python
cache.increment("failed-logins:42", expiration=900)
cache.touch("failed-logins:42", expiration=900)  # push the deadline out on every attempt
```

## Batch operations

Read or write many keys at once. `get_many()` is a single query and returns only the live entries:

```python
from plain.cache import cache

cache.set_many({"a": 1, "b": 2, "c": 3}, expiration=timedelta(minutes=5))

cache.get_many(["a", "b", "missing"])  # {"a": 1, "b": 2}

cache.delete_many(["a", "b"])  # returns the number deleted
```

## Refreshing expiration

To extend or change a live entry's expiration _without_ rewriting its value, use `touch()`:

```python
from datetime import timedelta
from plain.cache import cache

touched = cache.touch("my-key", expiration=timedelta(days=30))  # True if live, else False
```

`set()` always rewrites `value`, so refreshing a large entry's TTL re-TOASTs the whole blob. `touch()` writes only `expires_at` (and `updated_at`) — a heap-only write that reuses the existing TOAST pointer — so a multi-megabyte value isn't re-written. For refresh-heavy caches of large values (e.g. a conditional-request response cache with a sliding TTL), this avoids the dominant write cost.

`touch()` returns `False` for a missing or already-expired key (it won't resurrect an expired entry). Passing `expiration=None` clears the expiry so the entry never expires.

## Checking and deleting

There's no `exists()` — a single `get()` answers presence _and_ returns the value in one query, so check it directly:

```python
from plain.cache import cache

# Presence check (when None isn't a value you store)
if cache.get("my-key") is not None:
    ...

# Delete a single key (True if it existed)
cache.delete("my-key")

# Delete everything
cache.clear()  # returns the number of rows deleted
```

If `None` is a value you legitimately store, pass a sentinel default to tell "absent" from a stored `None`:

```python
missing = object()
if cache.get("my-key", missing) is not missing:
    ...  # a live entry exists (its value may be None)
```

To compute-and-store on a miss, reach for [`get_or_set()`](#get-or-set) rather than checking first — it's one query and avoids a check-then-set race.

## Querying cached items

The [`CachedItem`](./models.py#CachedItem) model includes a custom queryset with filters for common queries:

```python
from plain.cache.models import CachedItem

# Live entries (never-expiring or not-yet-expired) -- what reads use
live_items = CachedItem.query.live()

# Expired items (past their expiration)
expired_items = CachedItem.query.expired()

# Unexpired items with a *future* expiration date (excludes forever items)
unexpired_items = CachedItem.query.unexpired()

# Items with no expiration (cached forever)
forever_items = CachedItem.query.forever()
```

## Automatic cleanup

Expired cache items are not automatically deleted from the database. You can clean them up in two ways:

1. **Using chores**: If you have [plain.chores](/plain/plain/chores/README.md) set up, the `ClearExpired` chore will automatically delete expired items when chores run.

2. **Using the CLI**: Run `plain cache clear-expired` manually or in a scheduled task.

## CLI commands

The `plain cache` command group provides utilities for managing cached items:

- `plain cache stats` - Show cache statistics (total, expired, unexpired, forever counts)
- `plain cache clear-expired` - Delete all expired cache items
- `plain cache clear-all` - Delete all cache items (prompts for confirmation)

## Admin integration

If you have [plain.admin](/plain-admin/plain/admin/README.md) installed, `plain.cache` automatically registers an admin viewset. You can browse cached items, see their keys, values, and expiration dates in the admin interface under the "Cache" section.

## Settings

| Setting                               | Default |
| ------------------------------------- | ------- |
| `CACHE_AUTOVACUUM_SCALE_FACTOR`       | `0.1`   |
| `CACHE_TOAST_AUTOVACUUM_SCALE_FACTOR` | `0.05`  |

The cache table is a high-churn workload — every `set()` rewrites a row, and large values get TOASTed (Postgres' out-of-line storage), where each rewrite leaves orphaned chunks. Postgres' default autovacuum scale factor (`0.2`) waits until 20% of tuples are dead, which is too lax here. Plain ships tighter defaults so autovacuum keeps the heap and TOAST tables healthy without manual intervention.

These are applied as per-table storage parameters on `plaincache_cacheditem` by `plain postgres sync`. Override via `app/settings.py` or `PLAIN_CACHE_*` env vars. See [`default_settings.py`](./default_settings.py) for context.

## FAQs

#### What types of values can I cache?

Any JSON-serializable value: strings, numbers, booleans, lists, dicts, and None. Complex objects need to be serialized before caching.

#### What happens when I access an expired item?

`get()` returns the default (`None` unless you pass one) — an expired entry reads as absent. The row remains in the database until cleaned up (see [Automatic cleanup](#automatic-cleanup)).

#### How big can cached values be?

There's no hard limit. `plain.cache` works well for the typical mix — config, computed flags, tokens, short-lived results, occasional larger payloads. Once values get large enough to TOAST (Postgres' out-of-line storage, kicking in around a few KB), each rewrite produces orphaned TOAST chunks that autovacuum has to reclaim. The defaults in [Settings](#settings) are tuned for this; very high write rates on very large values may need additional tuning. If you're caching megabyte-sized blobs on every request, consider whether that data wants to live somewhere more permanent (a regular table, object storage) with the cache holding a reference instead — and use [`touch()`](#refreshing-expiration) to slide their TTLs instead of re-`set()`ing them.

## Installation

Install the `plain.cache` package from [PyPI](https://pypi.org/project/plain.cache/):

```bash
uv add plain.cache
```

Add `plain.cache` to your `INSTALLED_PACKAGES`:

```python
# app/settings.py
INSTALLED_PACKAGES = [
    # ...
    "plain.cache",
]
```

Sync the database to create the cache tables:

```bash
plain postgres sync
```

Try it out:

```python
from plain.cache import cache

cache.set("test-key", {"hello": "world"}, expiration=300)
print(cache.get("test-key"))  # {'hello': 'world'}
```
