docs: add human-friendly documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-11 13:02:40 +00:00
parent 32ede3b495
commit 6e418efb48
3 changed files with 518 additions and 0 deletions

219
docs/architecture.md Normal file
View file

@ -0,0 +1,219 @@
---
title: Architecture
description: Internals of go-cache -- types, data flow, storage format, and security model.
---
# Architecture
This document explains how `go-cache` works internally, covering its type
system, on-disc format, data flow, and security considerations.
## Core Types
### Cache
```go
type Cache struct {
medium io.Medium
baseDir string
ttl time.Duration
}
```
`Cache` is the primary handle. It holds:
- **medium** -- the storage backend (any `io.Medium` implementation).
- **baseDir** -- the root directory under which all cache files live.
- **ttl** -- how long entries remain valid after being written.
All three fields are set once during construction via `cache.New()` and are
immutable for the lifetime of the instance.
### Entry
```go
type Entry struct {
Data json.RawMessage `json:"data"`
CachedAt time.Time `json:"cached_at"`
ExpiresAt time.Time `json:"expires_at"`
}
```
`Entry` is the envelope written to storage. It wraps the caller's data as raw
JSON and adds two timestamps for expiry tracking. Using `json.RawMessage` means
the data payload is stored verbatim -- no intermediate deserialisation happens
during writes.
## Constructor Defaults
`cache.New(medium, baseDir, ttl)` applies sensible defaults when arguments are
zero-valued:
| Parameter | Zero value | Default applied |
|-----------|--------------|---------------------------------------------|
| `medium` | `nil` | `io.Local` (unsandboxed local filesystem) |
| `baseDir` | `""` | `.core/cache/` relative to the working dir |
| `ttl` | `0` | `cache.DefaultTTL` (1 hour) |
The constructor also calls `medium.EnsureDir(baseDir)` to guarantee the cache
directory exists before any reads or writes.
## Data Flow
### Writing (`Set`)
```
caller data
|
v
json.Marshal(data) -- serialise caller's value
|
v
wrap in Entry{ -- add timestamps
Data: <marshalled>,
CachedAt: time.Now(),
ExpiresAt: time.Now().Add(ttl),
}
|
v
json.MarshalIndent(entry) -- human-readable JSON
|
v
medium.Write(path, string) -- persist via the storage backend
```
The resulting file on disc (or equivalent record in another medium) looks like:
```json
{
"data": { "foo": "bar" },
"cached_at": "2026-03-10T14:30:00Z",
"expires_at": "2026-03-10T15:30:00Z"
}
```
Parent directories for nested keys (e.g. `github/host-uk/repos`) are created
automatically via `medium.EnsureDir()`.
### Reading (`Get`)
```
medium.Read(path)
|
v
json.Unmarshal -> Entry -- parse the envelope
|
v
time.Now().After(ExpiresAt)? -- check TTL
| |
yes no
| |
v v
return false json.Unmarshal(entry.Data, dest)
(cache miss) |
v
return true
(cache hit)
```
Key behaviours:
- If the file does not exist (`os.ErrNotExist`), `Get` returns `(false, nil)` --
a miss, not an error.
- If the file contains invalid JSON, it is treated as a miss (not an error).
This prevents corrupted files from blocking the caller.
- If the entry exists but has expired, it is treated as a miss. The stale file
is **not** deleted eagerly -- it remains on disc until explicitly removed or
overwritten.
### Deletion
- **`Delete(key)`** removes a single entry. If the file does not exist, the
operation succeeds silently.
- **`Clear()`** calls `medium.DeleteAll(baseDir)`, removing the entire cache
directory and all its contents.
### Age Inspection
`Age(key)` returns the `time.Duration` since the entry was written (`CachedAt`).
If the entry does not exist or cannot be parsed, it returns `-1`. This is useful
for diagnostics without triggering the expiry check that `Get` performs.
## Key-to-Path Mapping
Cache keys are mapped to file paths by appending `.json` and joining with the
base directory:
```
key: "github/host-uk/repos"
path: <baseDir>/github/host-uk/repos.json
```
Keys may contain forward slashes to create a directory hierarchy. This is how
the GitHub key helpers work:
```go
func GitHubReposKey(org string) string {
return filepath.Join("github", org, "repos")
}
func GitHubRepoKey(org, repo string) string {
return filepath.Join("github", org, repo, "meta")
}
```
## Security: Path Traversal Prevention
The `Path()` method guards against directory traversal attacks. After computing
the full path, it resolves both the base directory and the result to absolute
paths, then checks that the result is still a prefix of the base:
```go
if !strings.HasPrefix(absPath, absBase) {
return "", fmt.Errorf("invalid cache key: path traversal attempt")
}
```
This means a key like `../../etc/passwd` will be rejected before any I/O
occurs. Every public method (`Get`, `Set`, `Delete`, `Age`) calls `Path()`
internally, so traversal protection is always active.
## Concurrency
The `Cache` struct does not include a mutex. Concurrent reads are safe (each
call does independent file I/O), but concurrent writes to the **same key** may
produce a race at the filesystem level. If your application writes to the same
key from multiple goroutines, protect the call site with your own
synchronisation.
In practice, caches in this ecosystem are typically written by a single
goroutine (e.g. a CLI command fetching GitHub data) and read by others, which
avoids contention.
## Relationship to go-io
`go-cache` delegates all storage operations to the `io.Medium` interface from
`go-io`. It uses only five methods:
| Method | Used by |
|--------------|---------------------|
| `EnsureDir` | `New`, `Set` |
| `Read` | `Get`, `Age` |
| `Write` | `Set` |
| `Delete` | `Delete` |
| `DeleteAll` | `Clear` |
This minimal surface makes it straightforward to swap storage backends. For
tests, `io.NewMockMedium()` provides a fully in-memory implementation with no
disc access.

188
docs/development.md Normal file
View file

@ -0,0 +1,188 @@
---
title: Development
description: Building, testing, and contributing to go-cache.
---
# Development
This guide covers how to build, test, and contribute to `go-cache`.
## Prerequisites
- **Go 1.26** or later
- Access to `forge.lthn.ai` modules (`GOPRIVATE=forge.lthn.ai/*`)
- The `core` CLI (optional, for `core go test` and `core go qa`)
## Getting the Source
```bash
git clone ssh://git@forge.lthn.ai:2223/core/go-cache.git
cd go-cache
```
If you are working within the Go workspace at `~/Code/go.work`, the module is
already available locally and dependency resolution will use workspace overrides.
## Running Tests
With the `core` CLI:
```bash
core go test
```
With plain Go:
```bash
go test ./...
```
To run a single test:
```bash
core go test --run TestCache
# or
go test -run TestCache ./...
```
The test suite uses `io.NewMockMedium()` for all storage operations, so no
files are written to disc and tests run quickly in any environment.
## Test Coverage
```bash
core go cov # Generate coverage report
core go cov --open # Generate and open in browser
```
## Code Quality
The full QA pipeline runs formatting, vetting, linting, and tests in one
command:
```bash
core go qa # fmt + vet + lint + test
core go qa full # adds race detector, vulnerability scan, security audit
```
Individual steps:
```bash
core go fmt # Format with gofmt
core go vet # Static analysis
core go lint # Linter checks
```
## Project Structure
```
go-cache/
.core/
build.yaml # Build configuration (targets, flags)
release.yaml # Release configuration (changelog rules)
cache.go # Package source
cache_test.go # Tests
go.mod # Module definition
go.sum # Dependency checksums
docs/ # This documentation
```
The package is intentionally small -- a single source file and a single test
file. There are no sub-packages.
## Writing Tests
Tests follow the standard Go testing conventions. The codebase uses
`testing.T` directly (not testify assertions) for simplicity. When adding tests:
1. Use `io.NewMockMedium()` rather than the real filesystem.
2. Keep TTLs short (milliseconds) when testing expiry behaviour.
3. Name test functions descriptively: `TestCacheExpiry`, `TestCacheDefaults`, etc.
Example of testing cache expiry:
```go
func TestCacheExpiry(t *testing.T) {
m := io.NewMockMedium()
c, err := cache.New(m, "/tmp/test", 10*time.Millisecond)
if err != nil {
t.Fatalf("failed to create cache: %v", err)
}
c.Set("key", "value")
time.Sleep(50 * time.Millisecond)
var result string
found, _ := c.Get("key", &result)
if found {
t.Error("expected expired entry to be a cache miss")
}
}
```
## Commit Conventions
This project uses conventional commits:
```
feat(cache): add batch eviction support
fix(cache): handle corrupted JSON gracefully
refactor: simplify Path() traversal check
```
The release configuration (`.core/release.yaml`) includes `feat`, `fix`,
`perf`, and `refactor` in changelogs, and excludes `chore`, `docs`, `style`,
`test`, and `ci`.
## Build Configuration
The `.core/build.yaml` defines cross-compilation targets:
| OS | Architecture |
|---------|-------------|
| Linux | amd64 |
| Linux | arm64 |
| Darwin | arm64 |
| Windows | amd64 |
Since `go-cache` is a library (no `main` package), the build configuration is
primarily used by the CI pipeline for compilation checks rather than producing
binaries.
## Adding a New Storage Backend
To use the cache with a different storage medium, implement the `io.Medium`
interface from `go-io` and pass it to `cache.New()`. The cache only requires
five methods: `EnsureDir`, `Read`, `Write`, `Delete`, and `DeleteAll`. See
the [architecture](architecture.md) document for the full method mapping.
```go
import (
"forge.lthn.ai/core/go-cache"
"forge.lthn.ai/core/go-io/store"
"time"
)
// Use SQLite as the cache backend
medium, err := store.NewMedium("/path/to/cache.db")
if err != nil {
panic(err)
}
c, err := cache.New(medium, "cache", 30*time.Minute)
```
## Licence
EUPL-1.2. See the repository root for the full licence text.

111
docs/index.md Normal file
View file

@ -0,0 +1,111 @@
---
title: go-cache
description: File-based caching with TTL expiry, storage-agnostic via the go-io Medium interface.
---
# go-cache
`go-cache` is a lightweight, storage-agnostic caching library for Go. It stores
JSON-serialised entries with automatic TTL expiry and path-traversal protection.
**Module path:** `forge.lthn.ai/core/go-cache`
**Licence:** EUPL-1.2
## Quick Start
```go
import (
"fmt"
"time"
"forge.lthn.ai/core/go-cache"
)
func main() {
// Create a cache with default settings:
// - storage: local filesystem (io.Local)
// - directory: .core/cache/ in the working directory
// - TTL: 1 hour
c, err := cache.New(nil, "", 0)
if err != nil {
panic(err)
}
// Store a value
err = c.Set("user/profile", map[string]string{
"name": "Alice",
"role": "admin",
})
if err != nil {
panic(err)
}
// Retrieve it (returns false if missing or expired)
var profile map[string]string
found, err := c.Get("user/profile", &profile)
if err != nil {
panic(err)
}
if found {
fmt.Println(profile["name"]) // Alice
}
}
```
## Package Layout
| File | Purpose |
|-----------------|-------------------------------------------------------------|
| `cache.go` | Core types (`Cache`, `Entry`), CRUD operations, key helpers |
| `cache_test.go` | Tests covering set/get, expiry, delete, clear, defaults |
| `go.mod` | Module definition (Go 1.26) |
## Dependencies
| Module | Version | Role |
|-------------------------------|---------|---------------------------------------------|
| `forge.lthn.ai/core/go-io` | v0.0.3 | Storage abstraction (`Medium` interface) |
| `forge.lthn.ai/core/go-log` | v0.0.1 | Structured logging (indirect, via `go-io`) |
There are no other runtime dependencies. The test suite uses the standard
library only (plus the `MockMedium` from `go-io`).
## Key Concepts
### Storage Backends
The cache does not read or write files directly. All I/O goes through the
`io.Medium` interface defined in `go-io`. This means the same cache logic works
against:
- **Local filesystem** (`io.Local`) -- the default
- **SQLite KV store** (`store.Medium` from `go-io/store`)
- **S3-compatible storage** (`go-io/s3`)
- **In-memory mock** (`io.NewMockMedium()`) -- ideal for tests
Pass any `Medium` implementation as the first argument to `cache.New()`.
### TTL and Expiry
Every entry records both `cached_at` and `expires_at` timestamps. On `Get()`,
if the current time is past `expires_at`, the entry is treated as a cache miss
-- no stale data is ever returned. The default TTL is one hour
(`cache.DefaultTTL`).
### GitHub Cache Keys
The package includes two helper functions that produce consistent cache keys
for GitHub API data:
```go
cache.GitHubReposKey("host-uk") // "github/host-uk/repos"
cache.GitHubRepoKey("host-uk", "core") // "github/host-uk/core/meta"
```
These are convenience helpers used by other packages in the ecosystem (such as
`go-devops`) to avoid key duplication when caching GitHub responses.