Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'guides/**'
- 'docs/**'
- 'LICENSE'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'guides/**'
- 'docs/**'
- 'LICENSE'

jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v2
permissions:
contents: write
pull-requests: write
with:
enable-ct: true
enable-hank: true
enable-audit: true
enable-sbom: true
enable-sbom-scan: true
enable-dependency-submission: true
enable-mutate: true
enable-summary: true
secrets: inherit
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Release

on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'guides/**'
- 'docs/**'
- 'LICENSE'

jobs:
release:
uses: Taure/erlang-ci/.github/workflows/release.yml@v2
permissions:
contents: write
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
_build/
.rebar3/
*.beam
*.crashdump
rebar.lock
doc/
ebin/
.eunit/
logs/
*.iml
.idea/
.vscode/
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

All notable changes to this project will be documented in this file.

## [unreleased]

### Features

- Initial v0.1 scaffold: `nova_cache_adapter` and `nova_cache_invalidator` behaviours, public API, `nova_cache_ets` adapter, `nova_cache_invalidator_pg` transport, single-flight `fetch/3`.
62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,62 @@
# nova_cache
General-purpose KV cache library for the Nova ecosystem

General-purpose KV cache library for the Nova ecosystem.

`nova_cache` is **not** a dependency of Nova core and must never become one.

## Quick start

```erlang
%% sys.config
{nova_cache, [
{caches, #{
user_lookup => #{
adapter => nova_cache_ets,
ttl_default => 60_000,
max_size => 10_000
}
}}
]}.

%% application code
ok = nova_cache:put(user_lookup, <<"alice">>, #{role => admin}),
{ok, User} = nova_cache:get(user_lookup, <<"alice">>),
{ok, User} = nova_cache:fetch(user_lookup, <<"alice">>, fun load_user/0).
```

## Adapters

| Adapter | Status |
| ------------------ | ------ |
| `nova_cache_ets` | v0.1 |
| `nova_cache_redis` | v0.2 |

## Invalidation transports

| Transport | Status |
| --------------------------- | ------ |
| `nova_cache_invalidator_pg` | v0.1 |

## Build

```sh
rebar3 compile
rebar3 dialyzer
rebar3 xref
```

## Test

```sh
rebar3 ct
rebar3 eunit
rebar3 mutate
```

## Documentation

See the [guides](guides/) directory.

## License

Apache-2.0.
38 changes: 38 additions & 0 deletions cliff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[changelog]
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
trim = true

[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^docs", group = "Documentation" },
{ message = "^refactor", group = "Refactor" },
{ message = "^test", group = "Testing" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore", group = "Miscellaneous" },
{ message = "^ci", skip = true },
]
protect_breaking_commits = false
tag_pattern = "v[0-9].*"
sort_commits = "oldest"
38 changes: 38 additions & 0 deletions guides/adapters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Adapters

Adapters implement the `nova_cache_adapter` behaviour and own their own
storage process. The `State` returned from `start_link/2`-time registration is
opaque to `nova_cache` and passed back to every subsequent callback.

## Shipped adapters

### `nova_cache_ets`

In-process ETS table per cache. Direct concurrent reads and writes from the
caller's process. Periodic sweep purges expired rows. Soft `max_size`
enforced at sweep time with LRU-on-table-order eviction.

Configuration:

| Option | Default | Notes |
| ---------------- | ------------ | ----------------------------------------- |
| `ttl_default` | `infinity` | Default TTL applied when `put` omits one. |
| `max_size` | `infinity` | Soft bound; evictions happen on sweep. |
| `sweep_interval` | `60_000` | Milliseconds. |
| `invalidation` | `best_effort`| `best_effort | ttl_only | strict`. |

`strict` mode refuses to start without `ttl_default`.

## Writing a new adapter

1. `-behaviour(nova_cache_adapter).`
2. Implement `start_link/2`, `get/2`, `put/4`, `delete/2`, `delete_many/2`, `clear/1`.
3. Optionally implement `get_many/2` and `put_many/2`.
4. Register with `nova_cache_registry:register(Name, ?MODULE, State)` from
`init/1` so the public API can route calls.
5. Subscribe to invalidation events on startup if you want cluster
propagation.

Adapter callbacks may execute in the caller's process or proxy through the
adapter's own gen_server. That's the adapter's choice; the contract is the
return values, not the process topology.
71 changes: 71 additions & 0 deletions guides/getting-started.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Getting Started

## Installation

```erlang
{deps, [
{nova_cache, {git, "https://github.com/novaframework/nova_cache.git", {branch, "main"}}}
]}.
```

## Configuration

Declare your caches in `sys.config`:

```erlang
{nova_cache, [
{caches, #{
user_lookup => #{
adapter => nova_cache_ets,
ttl_default => 60_000,
max_size => 10_000,
sweep_interval => 60_000,
invalidation => best_effort
}
}},
{invalidator, nova_cache_invalidator_pg}
]}.
```

One supervised process per declared cache starts under `nova_cache_sup`.

## Reading and writing

```erlang
ok = nova_cache:put(user_lookup, <<"alice">>, User).
{ok, User} = nova_cache:get(user_lookup, <<"alice">>).
User = nova_cache:get(user_lookup, <<"alice">>, #{role => guest}).
```

## get-or-compute

```erlang
{ok, User} = nova_cache:fetch(user_lookup, <<"alice">>, fun() ->
case load_from_db(<<"alice">>) of
{ok, U} -> {ok, U};
not_found -> {error, not_found}
end
end).
```

Concurrent callers for the same key are deduplicated via single-flight.
Disable with `#{single_flight => false}` if you need pass-through semantics.

## Negative caching

Off by default. Opt in per call:

```erlang
%% short negative TTL (5 seconds):
nova_cache:fetch(user_lookup, <<"alice">>, F, #{cache_errors => {true, #{ttl => 5_000}}}).
```

## Invalidation across the cluster

```erlang
ok = nova_cache:invalidate(user_lookup, <<"alice">>).
```

The configured invalidator transport broadcasts the event to every subscribing
node. Each node purges its local copy on receipt. Delivery is best-effort
eventual; see the Invalidation guide for the failure model.
46 changes: 46 additions & 0 deletions guides/invalidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Invalidation

`nova_cache` ships cluster invalidation as a swappable transport behaviour.
The default transport is `nova_cache_invalidator_pg`, built on `pg`.

## Guarantee

**Best-effort eventual.** TTL is the correctness backstop. A node that is
netsplit, GC-paused, or just-joined may miss broadcasts and serve stale data
until the row expires.

## Per-cache mode

Configured via the `invalidation` key in the cache spec:

| Mode | Behaviour |
| ------------- | ------------------------------------------------------------------------ |
| `best_effort` | Subscribe to broadcasts; serve stale on miss. Default. |
| `ttl_only` | Skip broadcasts entirely; rely solely on TTL. |
| `strict` | Best-effort plus refuses to start without `ttl_default`. Bounds staleness.|

## Failure mode: a node misses a broadcast

It serves stale data until the row's TTL elapses. If the row was written with
`ttl => infinity`, it serves stale data indefinitely. This is the design.

For workloads where "indefinitely stale" is unacceptable, use `strict` mode
and a finite `ttl_default`.

## Failure mode: a node joins late

On join, the node's caches start empty. They subscribe and start receiving
broadcasts immediately. There is no backfill of historical events. Existing
entries on the joining node (e.g. after a netsplit heal) are not purged
automatically in `best_effort` mode -- if you need that, run `clear/1` from
your application's join handler.

## Writing a new transport

1. `-behaviour(nova_cache_invalidator).`
2. Implement `start_link/1`, `subscribe/2`, `broadcast/2`.
3. Deliver event payloads to subscribed handlers on every node.
4. Set `{nova_cache, [{invalidator, your_module}]}` in `sys.config`.

Transports must deliver events unchanged. They may drop events on failure
without compromising correctness, because TTL is the backstop.
36 changes: 36 additions & 0 deletions guides/telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Telemetry

`nova_cache` emits OpenTelemetry counters and spans when `opentelemetry_api`
is available at runtime. The dependency is optional; callers without it still
build and run.

## Counters

| Name | Attributes |
| ------------------- | ----------------------- |
| `nova_cache.hit` | `cache.name` |
| `nova_cache.miss` | `cache.name` |
| `nova_cache.evict` | `cache.name`, `reason` |

`reason` is `ttl` (lazy expiry on get), `sweep` (periodic sweeper), or
`max_size` (LRU eviction).

## Spans

| Name | Attributes |
| --------------------- | ------------------------------------------- |
| `nova_cache.fetch` | `cache.name`, `cache.key.length`, `result` |
| `nova_cache.put` | `cache.name`, `cache.key.length` |
| `nova_cache.invalidate` | `cache.name`, `cache.invalidate.scope` |

`result` is `hit | miss | loaded | error`.

## Enabling

Add `opentelemetry_api` to your project's `rebar.config`. `nova_cache`
detects the module at runtime and starts emitting events. No configuration on
`nova_cache` itself is required.

The OpenTelemetry-aware sibling library `opentelemetry_nova_cache` (planned
for v0.2) will install the trace/metric pipeline; until then, configure it in
your application's own OpenTelemetry setup.
Loading
Loading