Metadata-Version: 2.4
Name: small-cache
Version: 0.1.0
Summary: Compact in-process snapshot cache for structured items
Author: kam1k4dze
License-Expression: BSD-3-Clause
License-File: LICENSE
Project-URL: Homepage, https://github.com/kam1k4dze/small_cache
Requires-Python: >=3.14
Description-Content-Type: text/markdown

# small_cache

A memory-efficient in-process cache for structured items with typed attributes, implemented in C++23 and exposed to Python via [nanobind](https://github.com/wjakob/nanobind).

| CI | Status |
|----|--------|
| wheels | [![Wheel Action Status][actions-wheels-badge]][actions-wheels-link] |

[actions-wheels-link]: https://github.com/kam1k4dze/small_cache/actions?query=workflow%3AWheels
[actions-wheels-badge]: https://github.com/kam1k4dze/small_cache/workflows/Wheels/badge.svg

## Features

- Store items with up to **96 named attributes** per cache instance
- Supported attribute types: `bool`, `int` (64-bit), `float`, `str`, JSON (`bytes`), lists of `str`/JSON, `None` —
  each comes back as the type it was stored as
- **JSON answers** — getters return JSON written directly from the cache, with stored JSON embedded as is
- **Flat, compact storage** — every distinct string is stored once; strings of 64+ bytes are zstd-compressed
  with a dictionary trained on the data; each item is one packed record (absent and `None` attributes take no space)
- **Snapshots** — a reload is built aside and published at once; reads never block and never see a half-finished load
- **Transaction model** — bulk-update the cache, optionally evicting items the reload did not contain

## Installation

```bash
pip install small-cache
```

Only binary wheels are published, for CPython 3.14, regular and free-threaded (3.14t), on Linux, macOS and
Windows (64-bit). With free-threading the cache runs without the GIL: reads and loading proceed in parallel.

Build from source:

```bash
git clone https://github.com/kam1k4dze/small_cache
pip install ./small_cache
```

**Requirements:** Python ≥ 3.14; to build: CMake ≥ 3.28 and a C++23 compiler
(build dependencies: scikit-build-core 1.1.1, nanobind 3.1.0)

## Quick Start

```python
from small_cache import SmallCache

# Declare the attributes you care about (max 96)
cache = SmallCache(["name", "price", "in_stock", "tags"])

# A transaction wraps a full cache refresh cycle
cache.begin_transaction(estimated_number_of_items=1000)

cache.add("sku-001", {
    "name": "Widget",
    "price": 9.99,
    "in_stock": True,
    "tags": ["sale", "hardware"],
})
cache.add("sku-002", {"name": "Gadget", "price": 24.99, "in_stock": False})

cache.end_transaction()  # publishes the new data; items not re-added are evicted

# Answers are JSON bytes
cache.get_one("sku-001", ["name", "price"])
# b'["Widget",9.99]'

# Batch retrieval: an unknown id gets an empty row
cache.get_many(["sku-001", "sku-002", "sku-999"], ["name", "in_stock"])
# b'[["Widget",true],["Gadget",false],[]]'

cache.get_all_ids()
# b'["sku-001","sku-002"]'
```

## Transaction Model

`begin_transaction()` / `end_transaction()` bracket a full reload cycle:

- Items written with `add()` go into a new snapshot that readers cannot see yet. Until
  `end_transaction()`, every read is served from the current snapshot.
- `end_transaction()` publishes the new snapshot at once. Items that were **not** written in the transaction
  are dropped, or carried over unchanged with `remove_old_items=False`.
- `abort_transaction()` throws the new snapshot away; the current one keeps being served. Call it when a
  reload fails half-way, so a partial load is never published.
- Reads take no lock and can run from any thread. While a reload is being built, the old and the new
  snapshot are both in memory.

```python
# Keep stale items (incremental update)
cache.begin_transaction(remove_old_items=False)
cache.add("sku-003", {"name": "New item", "price": 5.0})
cache.end_transaction()  # pre-existing items survive

# Never publish a failed reload
cache.begin_transaction()
try:
    for item_id, attributes in fetch_items():
        cache.add(item_id, attributes)
except Exception:
    cache.abort_transaction()
    raise
cache.end_transaction()
```

## API Reference

### `SmallCache(attribute_names: list[str])`

Construct a cache instance. `attribute_names` lists every attribute that may be stored. Maximum 96 attributes; raises `RuntimeError` if the list is empty or exceeds the limit.

### `begin_transaction(estimated_number_of_items: int = 0, remove_old_items: bool = True)`

Open a write transaction. Optionally pre-allocates capacity. Raises `RuntimeError` if a transaction is already open.

### `end_transaction()`

Publish the transaction. With `remove_old_items=True` (default), items not written during this transaction are evicted. Raises `RuntimeError` if no transaction is open.

### `abort_transaction()`

Discard the transaction; the cache keeps serving what it had. Raises `RuntimeError` if no transaction is open.

### `add(item_id: str, attributes: dict[str, None | bool | int | float | str | bytes | list[str | bytes]])`

Insert or replace an item. Must be called inside a transaction. Attributes absent from `attribute_names` are
silently ignored. `bytes` is JSON text, kept as is: the getters write it into their answers unchanged, so it
must be valid JSON. List items are `str` or such JSON `bytes`. An `int` is stored as an integer and a `float` as
a float; any other type, or an `int` outside the signed 64-bit range, raises `TypeError`.

### `get_one(id: str, attributes: list[str]) -> bytes`

The item's values for `attributes`, in that order, as a UTF-8 JSON array. Attributes that were not set, or whose
name is not registered, are `null`; stored JSON is embedded as is; floats always carry a decimal point or exponent,
so they stay floats. `[]` if the item does not exist or `attributes` is empty. The answer is built without holding
the GIL. To return it from a JSON API without decoding it, wrap it, e.g. in `msgspec.Raw`.

### `get_many(ids: list[str], attributes: list[str]) -> bytes`

A JSON array with one `get_one` row per requested id.

### `get_all(attributes: list[str]) -> bytes`

A JSON array with one `get_one` row per item, in `get_all_ids()` order.

### `get_all_ids() -> bytes`

A JSON array of all item ids: the order items were added in, items carried over by `remove_old_items=False` last.

## Building from Source

```bash
git clone https://github.com/kam1k4dze/small_cache
cd small_cache
pip install -e .
```

Native C++ unit tests and benchmark (requires CMake ≥ 3.28, Ninja and a C++23 compiler with modules support):

```bash
cmake -B build -G Ninja && cmake --build build
./build/small_cache_native_test
```

`small_cache_bench` replays a recording of `add()` calls (JSON lines: a header `{"attributes": [...]}`, then one
`{"id": ..., "a": {...}}` per call) as a series of reloads and reports memory and timings:

```bash
./build/small_cache_bench adds.jsonl --cycles 3
heaptrack ./build/small_cache_bench adds.jsonl --cycles 2
```

Python tests:

```bash
pip install pytest
pytest tests/
```

## License

BSD-3-Clause — see [LICENSE](./LICENSE).