README.md

# CachedPaginator

ETS-backed pagination cache for Elixir applications.

## The Problem

In many applications, users repeatedly request the same expensive query but for different pages:

```
User A: GET /items?filters=X&page=1  → runs expensive query
User B: GET /items?filters=X&page=2  → runs the SAME expensive query again
User C: GET /items?filters=X&page=1  → runs the SAME expensive query again
...
```

Each page request triggers the full query, even though the underlying data hasn't changed. This wastes database resources and increases latency.

## The Solution

CachedPaginator caches query results once, then serves all page requests from the cache:

```
User A: GET /items?filters=X&page=1  → runs query, caches result
User B: GET /items?filters=X&page=2  → O(1) ETS lookup, no query
User C: GET /items?filters=X&page=1  → O(1) ETS lookup, no query
```

### Key Features

- **Query caching**: Run expensive query once, serve all page requests from cache
- **Locking**: When User A triggers a query, Users B and C wait for it to complete and reuse the same result (no thundering herd)
- **Keyset pagination**: Cursor encodes last sort key, stable across cache transitions
- **TTL + sweep**: Configurable TTL with periodic cleanup
- **ETS pool**: Pre-initialized pool of shared `ordered_set` tables (round-robin)
- **Telemetry**: Full observability (hits, misses, table count, memory usage)

## Installation

Add to your `mix.exs`:

```elixir
def deps do
  [
    {:cached_paginator, "~> 0.1.0"}
  ]
end
```

## Usage

### Define a cache module

```elixir
defmodule MyApp.PaginationCache do
  use CachedPaginator, otp_app: :my_app
end
```

Configure in `config/config.exs`:

```elixir
config :my_app, MyApp.PaginationCache,
  ttl: 300,
  sweep_interval: 5_000,
  pool_size: 100
```

Add to your supervision tree:

```elixir
children = [
  MyApp.PaginationCache
]
```

Runtime opts passed to `start_link/1` override app config:

```elixir
MyApp.PaginationCache.start_link(ttl: 1_000)
```

### Cache and paginate

```elixir
def list_items(filters, cursor, page_size) do
  {cache_location, cursor} =
    MyApp.PaginationCache.get_or_create(filters, fn ->
      # return {sort_key, value} tuples
      Repo.all(from i in Item, where: ^filters, select: {i.inserted_at, i.id})
    end, cursor)

  {table, cache_key, _size} = cache_location
  {items, updated_cursor} = MyApp.PaginationCache.fetch_after(table, cache_key, cursor, page_size)

  %{items: items, cursor: updated_cursor}
end
```

### Direct usage (without `use`)

You can also use `CachedPaginator` directly without a wrapper module:

```elixir
# In your supervision tree
children = [
  {CachedPaginator, name: :my_cache, ttl: 500}
]

# Call with explicit name
CachedPaginator.get_or_create(:my_cache, filters, &fetch/0, cursor)
```

## How It Works

### ETS Structure

Each cache entry stores items in a shared `ordered_set` ETS table using composite keys:

```
Data pool table (ordered_set):
{{cache_key, {sort_key}}, value}
{{cache_key, {sort_key1, sort_key2}}, value}
```

The `ordered_set` table type keeps keys sorted by Erlang term ordering. Combined with composite `{cache_key, sort_key}` keys, this enables efficient keyset pagination via `:ets.next/2` - walking forward from the last sort key to collect the next page.

### TTL

Cache entries expire after `ttl` milliseconds (default: 500ms). A periodic sweep runs every `sweep_interval` ms to clean up expired entries.

### Locking (Thundering Herd Prevention)

When multiple users request the same uncached data simultaneously:

1. First request acquires lock and runs query
2. Concurrent requests wait (poll every 50ms)
3. Once cached, all waiting requests get the same result

### ETS Pool

Tables are pre-initialized at startup and assigned to new cache entries via round-robin. Multiple cache entries coexist in the same table using composite keys, so table count stays constant regardless of how many queries are cached.

## Telemetry

### Events

| Event | Measurements | Metadata |
|-------|--------------|----------|
| `[:cached_paginator, :hit]` | - | `cache`, `filter_hash` |
| `[:cached_paginator, :miss]` | - | `cache`, `filter_hash` |
| `[:cached_paginator, :store]` | `duration`, `count` | `cache`, `filter_hash` |
| `[:cached_paginator, :sweep]` | `pool_size`, `memory_bytes`, `expired_count` | `cache` |

## Configuration Options

| Option | Default | Description |
|--------|---------|-------------|
| `:name` | required (auto-set by `use`) | GenServer name for this instance |
| `:ttl` | 500 | Cache entry TTL (ms) |
| `:sweep_interval` | 5_000 | Cleanup interval (ms) |
| `:pool_size` | 100 | Pre-initialized ETS tables |

When using `use CachedPaginator, otp_app: :my_app`, config is resolved in order: defaults → app env → runtime opts.

## License

MIT