Skip to content

Commit debcf65

Browse files
authored
feat: initial v0.1 scaffold (#1)
* feat: initial v0.1 scaffold Public API surface: get/2,3, put/3,4, fetch/3,4, delete/2, delete_many/2, invalidate/2, clear/1. ETS adapter with TTL sweep + soft LRU max_size, pg-based invalidation transport, ETS-backed single-flight fetch dedup. Adapter-owns-its-process model; State opaque to nova_cache. Time arithmetic in ms throughout. Invalidation is best-effort eventual, TTL is the correctness backstop. Per-cache invalidation => best_effort | ttl_only | strict, with strict refusing to start without ttl_default. Ships with Taure/erlang-ci v2 CI + release workflows, ex_doc guides for getting-started, adapters, invalidation, and telemetry. * ci: fix workflow input names + enable ct/hank/mutate
1 parent f308a82 commit debcf65

23 files changed

Lines changed: 1379 additions & 1 deletion

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths-ignore:
7+
- '**.md'
8+
- 'guides/**'
9+
- 'docs/**'
10+
- 'LICENSE'
11+
pull_request:
12+
branches: [main]
13+
paths-ignore:
14+
- '**.md'
15+
- 'guides/**'
16+
- 'docs/**'
17+
- 'LICENSE'
18+
19+
jobs:
20+
ci:
21+
uses: Taure/erlang-ci/.github/workflows/ci.yml@v2
22+
permissions:
23+
contents: write
24+
pull-requests: write
25+
with:
26+
enable-ct: true
27+
enable-hank: true
28+
enable-audit: true
29+
enable-sbom: true
30+
enable-sbom-scan: true
31+
enable-dependency-submission: true
32+
enable-mutate: true
33+
enable-summary: true
34+
secrets: inherit

.github/workflows/release.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths-ignore:
7+
- '**.md'
8+
- 'guides/**'
9+
- 'docs/**'
10+
- 'LICENSE'
11+
12+
jobs:
13+
release:
14+
uses: Taure/erlang-ci/.github/workflows/release.yml@v2
15+
permissions:
16+
contents: write

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
_build/
2+
.rebar3/
3+
*.beam
4+
*.crashdump
5+
rebar.lock
6+
doc/
7+
ebin/
8+
.eunit/
9+
logs/
10+
*.iml
11+
.idea/
12+
.vscode/

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
## [unreleased]
6+
7+
### Features
8+
9+
- 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`.

README.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,62 @@
11
# nova_cache
2-
General-purpose KV cache library for the Nova ecosystem
2+
3+
General-purpose KV cache library for the Nova ecosystem.
4+
5+
`nova_cache` is **not** a dependency of Nova core and must never become one.
6+
7+
## Quick start
8+
9+
```erlang
10+
%% sys.config
11+
{nova_cache, [
12+
{caches, #{
13+
user_lookup => #{
14+
adapter => nova_cache_ets,
15+
ttl_default => 60_000,
16+
max_size => 10_000
17+
}
18+
}}
19+
]}.
20+
21+
%% application code
22+
ok = nova_cache:put(user_lookup, <<"alice">>, #{role => admin}),
23+
{ok, User} = nova_cache:get(user_lookup, <<"alice">>),
24+
{ok, User} = nova_cache:fetch(user_lookup, <<"alice">>, fun load_user/0).
25+
```
26+
27+
## Adapters
28+
29+
| Adapter | Status |
30+
| ------------------ | ------ |
31+
| `nova_cache_ets` | v0.1 |
32+
| `nova_cache_redis` | v0.2 |
33+
34+
## Invalidation transports
35+
36+
| Transport | Status |
37+
| --------------------------- | ------ |
38+
| `nova_cache_invalidator_pg` | v0.1 |
39+
40+
## Build
41+
42+
```sh
43+
rebar3 compile
44+
rebar3 dialyzer
45+
rebar3 xref
46+
```
47+
48+
## Test
49+
50+
```sh
51+
rebar3 ct
52+
rebar3 eunit
53+
rebar3 mutate
54+
```
55+
56+
## Documentation
57+
58+
See the [guides](guides/) directory.
59+
60+
## License
61+
62+
Apache-2.0.

cliff.toml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[changelog]
2+
header = """
3+
# Changelog\n
4+
All notable changes to this project will be documented in this file.\n
5+
"""
6+
body = """
7+
{% if version %}\
8+
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
9+
{% else %}\
10+
## [unreleased]
11+
{% endif %}\
12+
{% for group, commits in commits | group_by(attribute="group") %}
13+
### {{ group | striptags | trim | upper_first }}
14+
{% for commit in commits %}
15+
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
16+
{{ commit.message | upper_first }}\
17+
{% endfor %}
18+
{% endfor %}\n
19+
"""
20+
trim = true
21+
22+
[git]
23+
conventional_commits = true
24+
filter_unconventional = true
25+
split_commits = false
26+
commit_parsers = [
27+
{ message = "^feat", group = "Features" },
28+
{ message = "^fix", group = "Bug Fixes" },
29+
{ message = "^docs", group = "Documentation" },
30+
{ message = "^refactor", group = "Refactor" },
31+
{ message = "^test", group = "Testing" },
32+
{ message = "^chore\\(release\\)", skip = true },
33+
{ message = "^chore", group = "Miscellaneous" },
34+
{ message = "^ci", skip = true },
35+
]
36+
protect_breaking_commits = false
37+
tag_pattern = "v[0-9].*"
38+
sort_commits = "oldest"

guides/adapters.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Adapters
2+
3+
Adapters implement the `nova_cache_adapter` behaviour and own their own
4+
storage process. The `State` returned from `start_link/2`-time registration is
5+
opaque to `nova_cache` and passed back to every subsequent callback.
6+
7+
## Shipped adapters
8+
9+
### `nova_cache_ets`
10+
11+
In-process ETS table per cache. Direct concurrent reads and writes from the
12+
caller's process. Periodic sweep purges expired rows. Soft `max_size`
13+
enforced at sweep time with LRU-on-table-order eviction.
14+
15+
Configuration:
16+
17+
| Option | Default | Notes |
18+
| ---------------- | ------------ | ----------------------------------------- |
19+
| `ttl_default` | `infinity` | Default TTL applied when `put` omits one. |
20+
| `max_size` | `infinity` | Soft bound; evictions happen on sweep. |
21+
| `sweep_interval` | `60_000` | Milliseconds. |
22+
| `invalidation` | `best_effort`| `best_effort | ttl_only | strict`. |
23+
24+
`strict` mode refuses to start without `ttl_default`.
25+
26+
## Writing a new adapter
27+
28+
1. `-behaviour(nova_cache_adapter).`
29+
2. Implement `start_link/2`, `get/2`, `put/4`, `delete/2`, `delete_many/2`, `clear/1`.
30+
3. Optionally implement `get_many/2` and `put_many/2`.
31+
4. Register with `nova_cache_registry:register(Name, ?MODULE, State)` from
32+
`init/1` so the public API can route calls.
33+
5. Subscribe to invalidation events on startup if you want cluster
34+
propagation.
35+
36+
Adapter callbacks may execute in the caller's process or proxy through the
37+
adapter's own gen_server. That's the adapter's choice; the contract is the
38+
return values, not the process topology.

guides/getting-started.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Getting Started
2+
3+
## Installation
4+
5+
```erlang
6+
{deps, [
7+
{nova_cache, {git, "https://github.com/novaframework/nova_cache.git", {branch, "main"}}}
8+
]}.
9+
```
10+
11+
## Configuration
12+
13+
Declare your caches in `sys.config`:
14+
15+
```erlang
16+
{nova_cache, [
17+
{caches, #{
18+
user_lookup => #{
19+
adapter => nova_cache_ets,
20+
ttl_default => 60_000,
21+
max_size => 10_000,
22+
sweep_interval => 60_000,
23+
invalidation => best_effort
24+
}
25+
}},
26+
{invalidator, nova_cache_invalidator_pg}
27+
]}.
28+
```
29+
30+
One supervised process per declared cache starts under `nova_cache_sup`.
31+
32+
## Reading and writing
33+
34+
```erlang
35+
ok = nova_cache:put(user_lookup, <<"alice">>, User).
36+
{ok, User} = nova_cache:get(user_lookup, <<"alice">>).
37+
User = nova_cache:get(user_lookup, <<"alice">>, #{role => guest}).
38+
```
39+
40+
## get-or-compute
41+
42+
```erlang
43+
{ok, User} = nova_cache:fetch(user_lookup, <<"alice">>, fun() ->
44+
case load_from_db(<<"alice">>) of
45+
{ok, U} -> {ok, U};
46+
not_found -> {error, not_found}
47+
end
48+
end).
49+
```
50+
51+
Concurrent callers for the same key are deduplicated via single-flight.
52+
Disable with `#{single_flight => false}` if you need pass-through semantics.
53+
54+
## Negative caching
55+
56+
Off by default. Opt in per call:
57+
58+
```erlang
59+
%% short negative TTL (5 seconds):
60+
nova_cache:fetch(user_lookup, <<"alice">>, F, #{cache_errors => {true, #{ttl => 5_000}}}).
61+
```
62+
63+
## Invalidation across the cluster
64+
65+
```erlang
66+
ok = nova_cache:invalidate(user_lookup, <<"alice">>).
67+
```
68+
69+
The configured invalidator transport broadcasts the event to every subscribing
70+
node. Each node purges its local copy on receipt. Delivery is best-effort
71+
eventual; see the Invalidation guide for the failure model.

guides/invalidation.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Invalidation
2+
3+
`nova_cache` ships cluster invalidation as a swappable transport behaviour.
4+
The default transport is `nova_cache_invalidator_pg`, built on `pg`.
5+
6+
## Guarantee
7+
8+
**Best-effort eventual.** TTL is the correctness backstop. A node that is
9+
netsplit, GC-paused, or just-joined may miss broadcasts and serve stale data
10+
until the row expires.
11+
12+
## Per-cache mode
13+
14+
Configured via the `invalidation` key in the cache spec:
15+
16+
| Mode | Behaviour |
17+
| ------------- | ------------------------------------------------------------------------ |
18+
| `best_effort` | Subscribe to broadcasts; serve stale on miss. Default. |
19+
| `ttl_only` | Skip broadcasts entirely; rely solely on TTL. |
20+
| `strict` | Best-effort plus refuses to start without `ttl_default`. Bounds staleness.|
21+
22+
## Failure mode: a node misses a broadcast
23+
24+
It serves stale data until the row's TTL elapses. If the row was written with
25+
`ttl => infinity`, it serves stale data indefinitely. This is the design.
26+
27+
For workloads where "indefinitely stale" is unacceptable, use `strict` mode
28+
and a finite `ttl_default`.
29+
30+
## Failure mode: a node joins late
31+
32+
On join, the node's caches start empty. They subscribe and start receiving
33+
broadcasts immediately. There is no backfill of historical events. Existing
34+
entries on the joining node (e.g. after a netsplit heal) are not purged
35+
automatically in `best_effort` mode -- if you need that, run `clear/1` from
36+
your application's join handler.
37+
38+
## Writing a new transport
39+
40+
1. `-behaviour(nova_cache_invalidator).`
41+
2. Implement `start_link/1`, `subscribe/2`, `broadcast/2`.
42+
3. Deliver event payloads to subscribed handlers on every node.
43+
4. Set `{nova_cache, [{invalidator, your_module}]}` in `sys.config`.
44+
45+
Transports must deliver events unchanged. They may drop events on failure
46+
without compromising correctness, because TTL is the backstop.

guides/telemetry.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Telemetry
2+
3+
`nova_cache` emits OpenTelemetry counters and spans when `opentelemetry_api`
4+
is available at runtime. The dependency is optional; callers without it still
5+
build and run.
6+
7+
## Counters
8+
9+
| Name | Attributes |
10+
| ------------------- | ----------------------- |
11+
| `nova_cache.hit` | `cache.name` |
12+
| `nova_cache.miss` | `cache.name` |
13+
| `nova_cache.evict` | `cache.name`, `reason` |
14+
15+
`reason` is `ttl` (lazy expiry on get), `sweep` (periodic sweeper), or
16+
`max_size` (LRU eviction).
17+
18+
## Spans
19+
20+
| Name | Attributes |
21+
| --------------------- | ------------------------------------------- |
22+
| `nova_cache.fetch` | `cache.name`, `cache.key.length`, `result` |
23+
| `nova_cache.put` | `cache.name`, `cache.key.length` |
24+
| `nova_cache.invalidate` | `cache.name`, `cache.invalidate.scope` |
25+
26+
`result` is `hit | miss | loaded | error`.
27+
28+
## Enabling
29+
30+
Add `opentelemetry_api` to your project's `rebar.config`. `nova_cache`
31+
detects the module at runtime and starts emitting events. No configuration on
32+
`nova_cache` itself is required.
33+
34+
The OpenTelemetry-aware sibling library `opentelemetry_nova_cache` (planned
35+
for v0.2) will install the trace/metric pipeline; until then, configure it in
36+
your application's own OpenTelemetry setup.

0 commit comments

Comments
 (0)