diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..2e5720d
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,5 @@
+# These are supported funding model platforms
+github: [christiangda]
+liberapay: christiangda
+patreon: christiangda
+custom: ["https://paypal.me/slashdevops"]
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..5e562fb
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,16 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "gomod" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000..39d2e33
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,15 @@
+---
+# https://docs.github.com/es/repositories/releasing-projects-on-github/automatically-generated-release-notes
+changelog:
+ categories:
+ - title: Breaking Changes ๐
+ labels:
+ - Semver-Major
+ - breaking-change
+ - title: New Features ๐
+ labels:
+ - Semver-Minor
+ - enhancement
+ - title: Other Changes
+ labels:
+ - "*"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..a67e94e
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,100 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL Advanced"
+
+on:
+ push:
+ branches: ["main"]
+ pull_request:
+ branches: ["main"]
+ schedule:
+ - cron: "10 12 * * 3"
+
+jobs:
+ analyze:
+ name: Analyze (${{ matrix.language }})
+ # Runner size impacts CodeQL analysis time. To learn more, please see:
+ # - https://gh.io/recommended-hardware-resources-for-running-codeql
+ # - https://gh.io/supported-runners-and-hardware-resources
+ # - https://gh.io/using-larger-runners (GitHub.com only)
+ # Consider using larger runners or machines with greater resources for possible analysis time improvements.
+ runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+ permissions:
+ # required for all workflows
+ security-events: write
+
+ # required to fetch internal or private CodeQL packs
+ packages: read
+
+ # only required for workflows in private repositories
+ actions: read
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - language: actions
+ build-mode: none
+ - language: go
+ build-mode: autobuild
+ # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
+ # Use `c-cpp` to analyze code written in C, C++ or both
+ # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
+ # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
+ # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
+ # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
+ # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
+ # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ # Add any setup steps before running the `github/codeql-action/init` action.
+ # This includes steps like installing compilers or runtimes (`actions/setup-node`
+ # or others). This is typically only required for manual builds.
+ # - name: Setup runtime (example)
+ # uses: actions/setup-example@v1
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: ${{ matrix.build-mode }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+
+ # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+ # queries: security-extended,security-and-quality
+
+ # If the analyze step fails for one of the languages you are analyzing with
+ # "We were unable to automatically build your code", modify the matrix above
+ # to set the build mode to "manual" for that language. Then modify this step
+ # to build your code.
+ # โน๏ธ Command-line programs to run using the OS shell.
+ # ๐ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+ - if: matrix.build-mode == 'manual'
+ shell: bash
+ run: |
+ echo 'If you are using a "manual" build mode for one or more of the' \
+ 'languages you are analyzing, replace this with the commands to build' \
+ 'your code, for example:'
+ echo ' make bootstrap'
+ echo ' make release'
+ exit 1
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml
new file mode 100644
index 0000000..b7570fd
--- /dev/null
+++ b/.github/workflows/pr.yaml
@@ -0,0 +1,102 @@
+name: Pull Request
+
+on:
+ pull_request:
+ branches:
+ - main
+
+env:
+ MAKE_STOP_ON_ERRORS: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ services:
+ valkey:
+ image: valkey/valkey:latest
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "valkey-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: ./go.mod
+
+ - name: Summary Information
+ run: |
+ echo "# Pull Request Summary" > $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Repository:** ${{ github.repository }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Pull Request:** ${{ github.event.pull_request.title }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Author:** ${{ github.event.pull_request.user.login }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Branch:** ${{ github.event.pull_request.head.ref }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Base:** ${{ github.event.pull_request.base.ref }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Commits:** ${{ github.event.pull_request.commits }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Changed Files:** ${{ github.event.pull_request.changed_files }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Additions:** ${{ github.event.pull_request.additions }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Deletions:** ${{ github.event.pull_request.deletions }}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ - name: Tools and versions
+ run: |
+ echo "## Tools and versions" >> $GITHUB_STEP_SUMMARY
+
+ ubuntu_version=$(lsb_release -a 2>&1 | grep "Description" | awk '{print $2, $3, $4}')
+ echo "Ubuntu version: $ubuntu_version"
+ echo "**Ubuntu Version:** $ubuntu_version" >> $GITHUB_STEP_SUMMARY
+
+ bash_version=$(bash --version | head -n 1 | awk '{print $4}')
+ echo "Bash version: $bash_version"
+ echo "**Bash Version:** $bash_version" >> $GITHUB_STEP_SUMMARY
+
+ git_version=$(git --version | awk '{print $3}')
+ echo "Git version: $git_version"
+ echo "**Git Version:** $git_version" >> $GITHUB_STEP_SUMMARY
+
+ go_version=$(go version | awk '{print $3}')
+ echo "Go version: $go_version"
+ echo "**Go Version:** $go_version" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ - name: Lint
+ uses: golangci/golangci-lint-action@v8
+ with:
+ version: latest
+
+ - name: Lines of code
+ run: |
+ echo "## Lines of code" >> $GITHUB_STEP_SUMMARY
+
+ go install github.com/boyter/scc/v3@latest
+ scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ - name: test
+ env:
+ C3E_TEST_VALKEY_ADDR: localhost:6379
+ run: |
+ echo "### Test report" >> $GITHUB_STEP_SUMMARY
+
+ go test -race -coverprofile=coverage.txt -covermode=atomic -tags=integration ./... | tee -a $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ - name: test coverage
+ run: |
+ echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY
+
+ go install github.com/vladopajic/go-test-coverage/v2@latest
+
+ # execute again to get the summary
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Coverage report" >> $GITHUB_STEP_SUMMARY
+ go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS โ
/g' | sed 's/FAIL/FAIL โ/g' | tee -a $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..4b08ad6
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,87 @@
+name: Release
+
+# https://help.github.com/es/actions/reference/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
+on:
+ push:
+ tags:
+ - v[0-9].[0-9]+.[0-9]*
+
+permissions:
+ contents: write
+ issues: write
+ pull-requests: write
+ id-token: write
+
+jobs:
+ release:
+ name: Release
+ runs-on: ubuntu-latest
+
+ services:
+ valkey:
+ image: valkey/valkey:latest
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "valkey-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ steps:
+ - name: Check out code
+ uses: actions/checkout@v7
+
+ - name: Set up Go 1.x
+ id: go
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: ./go.mod
+
+ - name: Summary Information
+ run: |
+ echo "# Build Summary" > $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Repository:** ${{ github.repository }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Who merge:** ${{ github.triggering_actor }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Commit ID:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
+ echo "**Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ - name: Lines of code
+ run: |
+ echo "## Lines of code" >> $GITHUB_STEP_SUMMARY
+
+ go install github.com/boyter/scc/v3@latest
+ scc --format html-table . | tee -a $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ - name: test
+ env:
+ C3E_TEST_VALKEY_ADDR: localhost:6379
+ run: |
+ echo "### Test report" >> $GITHUB_STEP_SUMMARY
+
+ go test -race -coverprofile=coverage.txt -covermode=atomic -tags=integration ./... | tee -a $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ - name: test coverage
+ run: |
+ echo "## Test Coverage" >> $GITHUB_STEP_SUMMARY
+
+ go install github.com/vladopajic/go-test-coverage/v2@latest
+
+ # execute again to get the summary
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Coverage report" >> $GITHUB_STEP_SUMMARY
+ go-test-coverage --config=./.testcoverage.yml | sed 's/PASS/PASS โ
/g' | sed 's/FAIL/FAIL โ/g' | tee -a $GITHUB_STEP_SUMMARY
+
+ - name: Release
+ uses: softprops/action-gh-release@v3
+ with:
+ tag_name: ${{ github.ref_name }}
+ name: ${{ github.ref_name }}
+ draft: false
+ prerelease: false
+ generate_release_notes: true
+ make_latest: true
diff --git a/.golangci.yaml b/.golangci.yaml
new file mode 100644
index 0000000..720ed32
--- /dev/null
+++ b/.golangci.yaml
@@ -0,0 +1,73 @@
+version: "2"
+linters:
+ # Enable specific linter
+ # https://golangci-lint.run/usage/linters/#enabled-by-default
+ enable:
+ - errcheck
+ - ineffassign
+ - staticcheck
+ - unused
+
+ # Disable specific linter
+ # https://golangci-lint.run/usage/linters/#disabled-by-default
+ disable:
+ # Enable presets.
+ # https://golangci-lint.run/usage/linters
+ # Default: []
+ - govet
+ - godot
+ - wsl
+ - testpackage
+ - whitespace
+ - tagalign
+ - nosprintfhostport
+ - nlreturn
+ - nestif
+ - mnd
+ - misspell
+ - lll
+ - godox
+ - funlen
+ - gochecknoinits
+ - depguard
+ - goconst
+ - dupword
+ - cyclop
+ - gocognit
+ - maintidx
+ - gocyclo
+ - dupl
+
+ settings:
+ errcheck:
+ # Report about not checking of errors in type assertions: `a := b.(MyStruct)`.
+ # Such cases aren't reported by default.
+ # Default: false
+ check-type-assertions: false
+ # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`.
+ # Such cases aren't reported by default.
+ # Default: false
+ check-blank: false
+ # To disable the errcheck built-in exclude list.
+ # See `-excludeonly` option in https://github.com/kisielk/errcheck#excluding-functions for details.
+ # Default: false
+ disable-default-exclusions: true
+ # List of functions to exclude from checking, where each entry is a single function to exclude.
+ # See https://github.com/kisielk/errcheck#excluding-functions for details.
+ exclude-functions:
+ - (*os.File).Close
+ - (io.Closer).Close
+ - io/ioutil.ReadFile
+ - io.Copy(*bytes.Buffer)
+ - io.Copy(os.Stdout)
+ - (io.Writer).Write
+ - (*bufio.Writer).Write
+ - (*encoding/json.Encoder).Encode
+ - os.Setenv
+ - os.Unsetenv
+ - fmt.Printf
+ - fmt.Print
+ - fmt.Println
+ - fmt.Fprint
+ - fmt.Fprintf
+ - (*strings.Builder).WriteString
diff --git a/.testcoverage.yml b/.testcoverage.yml
new file mode 100644
index 0000000..488ca42
--- /dev/null
+++ b/.testcoverage.yml
@@ -0,0 +1,27 @@
+# Configuration for github.com/vladopajic/go-test-coverage
+# https://github.com/vladopajic/go-test-coverage
+
+# (mandatory)
+# Path to the coverage profile file (output of `go test -coverprofile`).
+profile: coverage.txt
+
+# (optional; but recommended to set)
+# Strips this local prefix from reported file paths in the output.
+local-prefix: "github.com/slashdevops/c3e"
+
+# Coverage thresholds (percentages, [0-100]). Kept a comfortable margin below
+# the current measured coverage (~85%) so ordinary changes do not trip CI.
+threshold:
+ # Minimum coverage percentage required for individual files.
+ file: 0
+
+ # Minimum coverage percentage required for each package.
+ package: 70
+
+ # Minimum overall project coverage percentage required.
+ total: 75
+
+# Regexp rules to exclude matched files or packages from coverage statistics.
+exclude:
+ paths:
+ - ^examples/ # runnable example programs
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0e7622d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,172 @@
+# ๐ c3e โ Cache with Cascading Expiration Engine
+
+[](https://pkg.go.dev/github.com/slashdevops/c3e)
+[](https://golang.org/)
+[](https://valkey.io/)
+[](https://github.com/slashdevops/c3e/actions/workflows/codeql.yml)
+[](LICENSE)
+
+**c3e** is a high-performance, dependency-aware caching layer for Go built on top
+of [Valkey](https://valkey.io/) (Redis-compatible). It adds dependency tracking
+with cascading invalidation, stale-while-revalidate, thundering-herd protection,
+TTL jitter, and type-safe operations on top of a plain cache.
+
+## โจ Features
+
+- **๐ Dependency tracking & cascading invalidation** โ invalidate one entity and
+ every entry that depends on it is dropped, transitively.
+- **โก Stale-while-revalidate** โ serve cached data instantly while refreshing in
+ the background.
+- **๐ก๏ธ Thundering-herd protection** โ singleflight ensures one fetch per key
+ under concurrent load.
+- **๐ฏ Type-safe operations** โ generics give compile-time safety.
+- **๐ฒ TTL jitter** โ randomized expiry prevents synchronized stampedes.
+- **๐ฅ Fast fallback** โ a short query timeout falls back to your source when the
+ cache is slow or down.
+- **๐ Observability** โ an injectable `slog` logger plus metrics/tracing hooks.
+
+## ๐ฆ Installation
+
+```bash
+go get github.com/slashdevops/c3e
+```
+
+**Requirements:** Go 1.26+ ยท [valkey-go](https://github.com/valkey-io/valkey-go)
+v1.0.76+.
+
+## ๐ Quick start
+
+```go
+package main
+
+import (
+ "context"
+ "time"
+
+ "github.com/slashdevops/c3e"
+ "github.com/valkey-io/valkey-go"
+)
+
+type Project struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+}
+
+func main() {
+ // 1. Valkey client.
+ valkeyClient, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // fail fast instead of queueing when the cache is down
+ })
+ if err != nil {
+ panic(err)
+ }
+ defer valkeyClient.Close()
+
+ // 2. Cache stack.
+ cacheManager, err := c3e.NewCacheManager(valkeyClient, false)
+ if err != nil {
+ panic(err)
+ }
+ cache, err := c3e.NewSafeCacheManager(cacheManager, c3e.SafeCacheManagerConfig{
+ HardTTL: 5 * time.Minute, // absolute expiration
+ SoftTTL: 3 * time.Minute, // stale-after (โ60% of HardTTL)
+ JitterPercent: 0.1, // 10% TTL jitter
+ })
+ if err != nil {
+ panic(err)
+ }
+
+ // 3. Read through the cache. On a miss, the fetcher runs and its result is
+ // cached together with the entities it depends on.
+ ctx := context.Background()
+ id := c3e.CacheIdentifier{Type: "project", ID: "123"}
+
+ project, err := c3e.GetSafe(ctx, cache, id,
+ func(ctx context.Context) (Project, []c3e.CacheIdentifier, error) {
+ p := Project{ID: "123", Name: "Eagle", Owner: "user-456"}
+ deps := []c3e.CacheIdentifier{{Type: "user", ID: p.Owner}}
+ return p, deps, nil
+ })
+ if err != nil {
+ panic(err)
+ }
+ _ = project
+
+ // When the owner changes, every entry depending on it is invalidated.
+ _ = cache.Invalidate(ctx, c3e.CacheIdentifier{Type: "user", ID: "user-456"})
+}
+```
+
+## ๐๏ธ How it works
+
+`c3e` layers a low-level Valkey primitive under an application-facing,
+stale-while-revalidate API:
+
+```mermaid
+flowchart TD
+ App["Your application"]
+ subgraph c3e["c3e"]
+ direction TB
+ S["SafeCacheManager
stale-while-revalidate ยท singleflight ยท jitter ยท hooks"]
+ C["CacheManager
data + dependency graph"]
+ R["CacheRepository
Valkey/Redis client"]
+ S --> C --> R
+ end
+ App -->|"Get / Invalidate"| S
+ R --> VK[("Valkey / Redis")]
+ S -. "miss ยท stale ยท error" .-> DB[("Source of truth")]
+```
+
+Every `Get` resolves to one outcome, reported to the `OnGet` hook as a `Result`:
+
+```mermaid
+flowchart TD
+ G(["Get(id, fetcher)"]) --> L{"lookup
(bounded by QueryTimeout)"}
+ L -->|"fresh ยท age โค SoftTTL"| H["serve cached
โ hit"]
+ L -->|"stale ยท SoftTTL < age โค HardTTL"| ST["serve stale now
+ refresh in background
โ stale"]
+ L -->|"not cached"| M["fetch ยท cache ยท serve
โ miss"]
+ L -->|"slow / error"| E["fetch ยท serve
โ timeout / error"]
+```
+
+Fresh and stale both serve immediately; miss/timeout/error fall back to the
+source with concurrent callers collapsed into a single fetch. For the full
+picture โ the key scheme and the dependency reverse index โ see
+[docs/architecture.md](docs/architecture.md).
+
+## ๐ Documentation
+
+Full documentation lives in [`docs/`](docs/README.md):
+
+- [Architecture](docs/architecture.md) โ layers, read path, key scheme, cascade.
+- [Usage](docs/usage.md) โ worked examples and composition patterns.
+- [Configuration](docs/configuration.md) โ every config field and how to tune it.
+- [Observability](docs/observability.md) โ the logger and the hooks.
+- [Best practices](docs/best-practices.md) โ do/don't and troubleshooting.
+- [Limitations & semantics](docs/limitations.md) โ the invalidation contract and
+ consistency model. **Read this before production use.**
+
+API reference: [pkg.go.dev/github.com/slashdevops/c3e](https://pkg.go.dev/github.com/slashdevops/c3e).
+
+## ๐ค Contributing
+
+Contributions are welcome. Please make sure the suite is green before opening a
+pull request:
+
+```bash
+go test -race ./... # unit tests
+docker run -d --rm -p 6379:6379 valkey/valkey:latest # for the integration tag
+go test -race -tags=integration ./...
+```
+
+## ๐ License
+
+Licensed under the Apache License 2.0 โ see [LICENSE](LICENSE).
+
+## ๐ Acknowledgments
+
+- Built for [Valkey](https://valkey.io/), the high-performance Redis alternative.
+- Inspired by HTTP `Cache-Control` `stale-while-revalidate`.
+- Thundering-herd protection via
+ [golang.org/x/sync/singleflight](https://pkg.go.dev/golang.org/x/sync/singleflight).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..8fa5a0a
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,16 @@
+# Security Policy
+
+This project uses [GitHub CodeQL](https://codeql.github.com/) to scan the code for
+vulnerabilities. You can see the report in the pipeline
+[](https://github.com/slashdevops/c3e/actions/workflows/codeql.yml)
+
+## Supported Versions
+
+| Version | Supported |
+| ------- | ------------------ |
+| 1.0.x | :white_check_mark: |
+
+## Reporting a Vulnerability
+
+Please use the [Project Issues --> Report a vulnerability](https://github.com/slashdevops/c3e/issues/new/choose)
+to report it.
diff --git a/dep_ttl_test.go b/dep_ttl_test.go
new file mode 100644
index 0000000..c2ac94e
--- /dev/null
+++ b/dep_ttl_test.go
@@ -0,0 +1,65 @@
+package c3e
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+)
+
+// realClient dials a local Valkey, skipping the test when none is reachable.
+func realClient(t *testing.T) valkey.Client {
+ t.Helper()
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableCache: true,
+ DisableRetry: true,
+ })
+ if err != nil {
+ t.Skip("Skipping: no Valkey at localhost:6379")
+ }
+ if err := client.Do(context.Background(), client.B().Ping().Build()).Error(); err != nil {
+ client.Close()
+ t.Skip("Skipping: Valkey not reachable at localhost:6379")
+ }
+ return client
+}
+
+// TestSet_dependencyMetadataHasTTL verifies that the reverse-dependency set and
+// the forward-dependency list are given a TTL, so they cannot leak when a cache
+// entry expires without an explicit Invalidate.
+func TestSet_dependencyMetadataHasTTL(t *testing.T) {
+ client := realClient(t)
+ defer client.Close()
+ ctx := context.Background()
+
+ cm, err := NewCacheManager(client, false)
+ if err != nil {
+ t.Fatalf("NewCacheManager: %v", err)
+ }
+
+ child := CacheIdentifier{Type: "c3e_test_child", ID: "1"}
+ parent := CacheIdentifier{Type: "c3e_test_parent", ID: "1"}
+ revKey := depKey(parent) // dep:c3e_test_parent:1
+ fwdKey := depsForKey(cacheKey(child)) // deps-for:cache:c3e_test_child:1
+
+ // Clean any leftovers, then cache the child depending on the parent.
+ client.Do(ctx, client.B().Del().Key(cacheKey(child), revKey, fwdKey).Build())
+ if err := cm.Set(ctx, child, []byte(`{"x":1}`), []CacheIdentifier{parent}, time.Hour); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ t.Cleanup(func() {
+ client.Do(ctx, client.B().Del().Key(cacheKey(child), revKey, fwdKey).Build())
+ })
+
+ for _, k := range []string{revKey, fwdKey} {
+ ttl, err := client.Do(ctx, client.B().Ttl().Key(k).Build()).AsInt64()
+ if err != nil {
+ t.Fatalf("TTL %s: %v", k, err)
+ }
+ if ttl <= 0 {
+ t.Errorf("key %s has TTL %d, want > 0 (no leak)", k, ttl)
+ }
+ }
+}
diff --git a/docs.go b/docs.go
new file mode 100644
index 0000000..e7c693e
--- /dev/null
+++ b/docs.go
@@ -0,0 +1,225 @@
+// Package c3e is a high-performance, dependency-aware caching layer built
+// on top of [Valkey] (Redis-compatible).
+//
+// # Why the name?
+//
+// The package is named c3e โ pronounced "see three ee" โ using the same
+// numeric-contraction style as well-known community identifiers like
+// k8s (Kubernetes), i18n (Internationalization), and l10n
+// (Localization). The leading and trailing letters are kept; the digit
+// counts the omitted letters in the middle.
+//
+// It admits two complementary readings, both intended:
+//
+// 1. As a numeronym for "cache": c ยท 3 ยท e, where "ach" is the
+// three-letter middle that the digit replaces. The package
+// implements a cache.
+// 2. As an initialism for "Cache with Cascading Expiration Engine" โ
+// the expansion called out in the package's [README]: a one-letter
+// "Cache" prefix, the digit 3 standing in for the three-word
+// descriptor (Cache ยท Cascading ยท Expiration), and the trailing
+// "Engine".
+//
+// Both readings point at the same thing โ a cache engine with
+// cascading expiration.
+//
+// Go's style guide discourages "container" package names (util, common,
+// helpers, misc) โ but it accepts widely-understood numeronyms when
+// they are documented and disambiguated. This docs.go is that
+// documentation.
+//
+// # Overview
+//
+// The package provides:
+//
+// - Dependency tracking with cascading invalidation when a depended-on
+// entity changes.
+// - Stale-while-revalidate: serve cached data immediately while a
+// background refresh re-fetches behind the scenes.
+// - Thundering-herd protection via singleflight, so only one goroutine
+// fetches data for a given key under concurrent load.
+// - Optional client-side caching (Valkey-tracking) for extreme read
+// performance.
+// - TTL jitter to prevent synchronized cache stampedes.
+// - Type-safe operations via a generic wrapper.
+// - An injectable logger and observability hooks (hit/miss/stale/refresh/
+// invalidate) for metrics and tracing without wrapping the manager.
+//
+// # Architecture
+//
+// The package layers from low-level to application-facing:
+//
+// 1. CacheRepository โ abstraction over the Valkey client.
+// 2. CacheManager (low-level) โ direct Valkey operations and
+// dependency-graph maintenance.
+// 3. SafeCacheManager (high-level) โ application-facing API enforcing
+// stale-while-revalidate, singleflight, jitter, and best practices.
+// 4. TypedSafeCacheManager (high-level, generic) โ type-safe wrapper
+// around SafeCacheManager for a specific value type.
+//
+// # Basic usage
+//
+// // Construct the Valkey client and the cache stack.
+// valkeyClient, _ := valkey.NewClient(valkey.ClientOption{
+// InitAddress: []string{"localhost:6379"},
+// DisableRetry: true, // do not queue when the cache is down
+// })
+//
+// cacheManager, err := c3e.NewCacheManager(valkeyClient, false)
+// if err != nil {
+// // handle error
+// }
+//
+// safeCacheManager, err := c3e.NewSafeCacheManager(cacheManager, c3e.SafeCacheManagerConfig{
+// HardTTL: 5 * time.Minute, // absolute expiration
+// SoftTTL: 3 * time.Minute, // stale-after time
+// JitterPercent: 0.1, // 10% TTL jitter
+// })
+// if err != nil {
+// // handle error
+// }
+//
+// // Option A โ the standalone generic helper (recommended).
+// identifier := c3e.CacheIdentifier{Type: "project", ID: "123"}
+// project, err := c3e.GetSafe(ctx, safeCacheManager, identifier,
+// func(ctx context.Context) (Project, []c3e.CacheIdentifier, error) {
+// p, err := db.GetProject(ctx, "123")
+// if err != nil {
+// return Project{}, nil, err
+// }
+// deps := []c3e.CacheIdentifier{
+// {Type: "user", ID: "456"},
+// {Type: "org", ID: "789"},
+// }
+// return p, deps, nil
+// })
+//
+// // Option B โ the typed wrapper for repeated Get/Invalidate of one type.
+// projectCache := c3e.NewTypedSafeCacheManager[Project](safeCacheManager)
+// project, err = projectCache.Get(ctx, identifier, fetcherFunc)
+//
+// # Dependency tracking
+//
+// Every cached entry can declare a list of dependencies. When any of
+// those dependencies is invalidated, the entry is invalidated too:
+//
+// // A project depends on its owner (user:456) and organization (org:789).
+// // When user:456 is updated, the project cache is automatically dropped.
+// safeCacheManager.Invalidate(ctx, c3e.CacheIdentifier{Type: "user", ID: "456"})
+//
+// Cascades are transitive: dependents of dependents are also
+// invalidated.
+//
+// Invalidation semantics to keep in mind:
+//
+// - It cascades DOWNWARD only (to dependents), never up to an entry's own
+// dependencies.
+// - It is eventually consistent, not atomic: Set and the cascade run as
+// pipelined commands, so a dependent written during an in-flight cascade
+// may be missed; it self-heals at the next write or via TTL. Design for
+// at-least-once invalidation.
+// - Dependency links only cover items already present in a cached value, so
+// adding a new item to a cached collection is invisible to the graph โ
+// invalidate the collection's own key when its membership changes.
+// - The dependency-tracking keys expire with the entry TTL (refreshed on each
+// write), so they cannot leak when an entry expires naturally.
+//
+// # Stale-while-revalidate
+//
+// SafeCacheManager classifies every read and reports the outcome to the OnGet
+// hook as a [Result]:
+//
+// - hit โ age โค SoftTTL. Served immediately.
+// - stale โ SoftTTL < age โค HardTTL. Served immediately while a background
+// goroutine refreshes the entry. The refresh runs on a
+// [context.WithoutCancel] copy of the caller's context (bounded by its own
+// timeout) so it survives the caller returning.
+// - miss โ age > HardTTL or the entry is absent. The caller blocks while
+// the fetcher runs (under singleflight).
+// - timeout / error โ the cache did not answer within QueryTimeout, or
+// returned an error; the caller falls back to the fetcher immediately.
+//
+// The pattern hides cold-start latency while still keeping data
+// reasonably fresh.
+//
+// # Error handling
+//
+// The package defines sentinel error values for predictable matching:
+//
+// - [ErrCacheMiss] โ item not found in the cache.
+// - [ErrCommandExecution] โ the underlying Valkey command failed.
+// - [ErrGetOldDependencies] โ failed to retrieve the previous
+// dependency list during invalidation.
+// - [ErrGetDependents] โ failed to retrieve dependents.
+//
+// Match them with [errors.Is]:
+//
+// identifier := c3e.CacheIdentifier{Type: "user", ID: "123"}
+// data, err := cacheManager.Get(ctx, identifier, 0)
+// if errors.Is(err, c3e.ErrCacheMiss) {
+// // handle cache miss
+// }
+//
+// # Key format
+//
+// Keys follow a hierarchical, prefix-based convention so cache, forward
+// dependency, and reverse dependency keys never collide:
+//
+// - cache:{type}:{id} โ value entries (e.g. "cache:project:123").
+// - dep:{type}:{id} โ reverse-dependency sets.
+// - deps-for:cache:{type}:{id} โ forward-dependency lists.
+//
+// # Thread safety
+//
+// All exported operations are safe for concurrent use. SafeCacheManager
+// uses singleflight so that, no matter how many goroutines race for the
+// same missing key, only one fetcher runs and the others wait for its
+// result.
+//
+// # Best practices
+//
+// 1. Use SafeCacheManager (or its typed wrapper) in application code,
+// not CacheManager directly.
+// 2. Choose SoftTTL at 60-80% of HardTTL for a useful stale window.
+// 3. Use 5-10% jitter to break synchronized TTL expirations across
+// replicas.
+// 4. Keep dependency lists small and intentional โ each entry adds
+// bookkeeping cost on Set and cascade work on Invalidate.
+// 5. Prefer specific entity types ("project", "user", "policy") over
+// generic ones ("data", "item").
+// 6. Declare dependencies completely โ correctness relies on the fetcher
+// returning every entity a value depends on.
+// 7. Invalidate a collection's key when its membership changes; inserts are
+// invisible to the dependency graph (see the invalidation semantics above).
+//
+// # Performance characteristics
+//
+// - Get (hit) โ O(1): single Valkey GET.
+// - Get (miss) โ O(1) cache work + fetcher time + O(D) dependency
+// writes, where D is the dependency count.
+// - Set โ O(D).
+// - Invalidate โ O(NยทD), where N is cascade depth and D is the
+// average dependency count at each level.
+//
+// # Observability
+//
+// The manager logs via an injectable [slog.Logger]
+// (SafeCacheManagerConfig.Logger; defaults to [slog.Default]). Inject a scoped
+// logger to namespace cache logs, or a discard handler to silence them โ a
+// library should never write to your global logger unasked.
+//
+// For metrics and tracing, attach [Hooks] (SafeCacheManagerConfig.Hooks)
+// instead of wrapping the manager:
+//
+// - OnGet reports every read outcome ([Result]: hit, stale, miss, timeout,
+// error) and its latency โ enough for hit ratio, fallback rate, and
+// latency histograms.
+// - OnRefresh reports background stale-while-revalidate refresh health.
+// - OnInvalidate reports invalidations and their latency.
+//
+// Any nil callback is skipped, so the zero Hooks is a no-op. Callbacks must be
+// non-blocking and safe for concurrent use.
+//
+// [Valkey]: https://valkey.io
+// [README]: ./README.md
+package c3e
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..abe4c26
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,21 @@
+# c3e documentation
+
+`c3e` (**C**ache with **C**ascading **E**xpiration **E**ngine) is a
+dependency-aware caching layer for Go built on top of
+[Valkey](https://valkey.io/) (Redis-compatible).
+
+The root [README](../README.md) covers installation and a first example. These
+pages go deeper:
+
+| Page | What it covers |
+| --- | --- |
+| [Architecture](architecture.md) | The three-layer design, the stale-while-revalidate read path, the key scheme, and the dependency reverse index โ with diagrams. |
+| [Usage](usage.md) | Worked examples: basic, type-safe, dependency tracking, `GetOrFetch`, and advanced composition patterns. |
+| [Configuration](configuration.md) | Every `SafeCacheManagerConfig` field, its validation, and how to choose values. |
+| [Observability](observability.md) | The injectable logger and the metrics/tracing hooks. |
+| [Best practices](best-practices.md) | Do / don't guidance and a troubleshooting guide. |
+| [Limitations & semantics](limitations.md) | The invalidation contract, consistency model, error handling, and backend constraints โ read this before relying on it in production. |
+
+The Go API reference lives on
+[pkg.go.dev/github.com/slashdevops/c3e](https://pkg.go.dev/github.com/slashdevops/c3e)
+(generated from the package doc in `docs.go`).
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..55602bc
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,162 @@
+# Architecture
+
+## Three-layer design
+
+`c3e` layers from a low-level Valkey primitive up to an application-facing,
+type-safe API. Each layer is optional to peel back.
+
+```mermaid
+flowchart TD
+ App["Your application"]
+
+ subgraph c3e["c3e"]
+ direction TB
+ T["TypedSafeCacheManager[T] ยท GetSafe[T]()
compile-time type safety โ optional"]
+ S["SafeCacheManager
stale-while-revalidate ยท singleflight
TTL jitter ยท query timeout ยท hooks"]
+ C["CacheManager
data + dependency graph ยท batched commands"]
+ R["CacheRepository
Valkey/Redis client ยท optional client-side cache"]
+ T --> S --> C --> R
+ end
+
+ App -->|"Get / Invalidate"| T
+ App -->|"Get / Invalidate"| S
+ R --> VK[("Valkey / Redis")]
+ S -. "cache miss ยท stale ยท error" .-> DB[("Source of truth
DB ยท API ยท โฆ")]
+```
+
+- **`TypedSafeCacheManager[T]`** / **`GetSafe[T]()`** โ a generic wrapper that
+ adds compile-time type safety. Optional.
+- **`SafeCacheManager`** โ the application-facing API. Enforces
+ stale-while-revalidate, singleflight thundering-herd protection, TTL jitter,
+ a bounded query timeout, and observability hooks. **Use this in application
+ code.**
+- **`CacheManager`** โ the low-level primitive. Direct Valkey operations plus
+ dependency-graph maintenance.
+- **`CacheRepository`** โ the interface over the Valkey client; mock it in
+ tests.
+
+## Read path (stale-while-revalidate)
+
+The outcome of every `Get` maps 1:1 to the `Result` reported to the `OnGet`
+observability hook (`hit` ยท `stale` ยท `miss` ยท `timeout` ยท `error`):
+
+```mermaid
+flowchart TD
+ G(["Get(id, fetcher)"]) --> L{"lookup in cache
(bounded by QueryTimeout)"}
+
+ L -->|"fresh ยท age โค SoftTTL"| H["serve cached value
โ hit"]
+ L -->|"stale ยท SoftTTL < age โค HardTTL"| ST["serve stale value now
โ stale"]
+ ST --> BG["refresh in background
detached context ยท single-flighted"] --> UP["update cache"]
+ L -->|"not cached"| M["fetch from source ยท cache ยท serve
โ miss"]
+ L -->|"slower than QueryTimeout"| TO["fetch from source ยท serve
โ timeout"]
+ L -->|"cache/network error"| E["fetch from source ยท serve
โ error"]
+
+ M -. "concurrent callers for the same key
wait on one fetch (singleflight)" .- ST
+```
+
+- **Fresh** and **stale** both serve *immediately* โ stale never blocks the
+ caller; the refresh is detached (runs on a `context.WithoutCancel` copy of the
+ request context, bounded by its own timeout) so it survives the request
+ returning.
+- **Miss / timeout / error** fall back to the source; concurrent callers for the
+ same key are collapsed into a single fetch (thundering-herd protection).
+
+## Cache states
+
+```mermaid
+stateDiagram-v2
+ direction LR
+ [*] --> Fresh: written
+ Fresh --> Stale: age exceeds SoftTTL
+ Stale --> Expired: age exceeds HardTTL
+ Expired --> [*]
+
+ Fresh: FRESH ยท served immediately
+ Stale: STALE ยท served now + refreshed in background
+ Expired: EXPIRED / MISS ยท blocking fetch from source
+```
+
+## Key scheme & the dependency reverse index
+
+Each cached item has a structured identifier:
+
+```go
+type CacheIdentifier struct {
+ Type string // Entity type (e.g., "user", "project", "order")
+ ID string // Entity ID (e.g., "123", "user-456")
+}
+```
+
+Three key families model the dependency graph so data, forward, and reverse
+keys never collide:
+
+| Key | Meaning |
+| --- | --- |
+| `cache::` | the cached data (has a TTL) |
+| `deps-for:cache::` | **forward** list: what this entry depends on |
+| `dep::` | **reverse** set: which entries depend on this entity |
+
+Caching `project:123` with dependencies on `user:456` and `org:789` builds a
+reverse index โ so changing an entity finds every entry that must be dropped:
+
+```mermaid
+graph LR
+ P123["cache:project:123"] -->|depends on| U["user:456"]
+ P124["cache:project:124"] -->|depends on| U
+ DASH["cache:dashboard:1"] -->|depends on| U
+ P123 -->|depends on| O["org:789"]
+
+ U -. "reverse set dep:user:456" .-> P123
+ U -. "dep:user:456" .-> P124
+ U -. "dep:user:456" .-> DASH
+```
+
+When `user:456` is invalidated, its reverse set names every dependent, which are
+deleted and cascaded โ **downward only** (dependents), never up to dependencies:
+
+```mermaid
+flowchart TD
+ I(["Invalidate(user:456)"]) --> D0["delete cache:user:456
+ clean its forward-dep links"]
+ D0 --> Q["read dep:user:456
= entries that depend on user:456"]
+ Q --> D1["delete each dependent
e.g. cache:project:123, cache:dashboard:1"]
+ D1 --> CAS{"does a dependent have
its own dependents?"}
+ CAS -->|yes| Q
+ CAS -->|no| DONE(["done"])
+```
+
+> โ ๏ธ **You must invalidate a collection's key on inserts.** Dependency links
+> only exist for items *already in* a cached list, so adding a new item to a
+> cached collection is invisible to the graph โ invalidate the collection's own
+> key (or let it expire via TTL) when membership changes. See
+> [Limitations](limitations.md).
+
+## Performance characteristics
+
+| Operation | Time complexity | Notes |
+| --- | --- | --- |
+| `Get` (hit) | O(1) | single Valkey `GET` |
+| `Get` (miss) | O(1) + fetch + O(D) | D = dependency count |
+| `Set` | O(D) | D = number of dependencies |
+| `Invalidate` | O(N ร D) | N = cascade depth, D = avg deps per level |
+
+## Thread safety
+
+All exported operations are safe for concurrent use:
+
+- Concurrent reads of the same missing key are coalesced via singleflight, so
+ only one fetcher runs.
+- Dependency tracking uses Valkey server-side operations.
+- No manual locking is required.
+
+```go
+// Safe to call concurrently from many goroutines โ only ONE fetcher runs.
+var wg sync.WaitGroup
+for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ cache.Get(ctx, identifier, &result, fetcher)
+ }()
+}
+wg.Wait()
+```
diff --git a/docs/best-practices.md b/docs/best-practices.md
new file mode 100644
index 0000000..736c8e1
--- /dev/null
+++ b/docs/best-practices.md
@@ -0,0 +1,67 @@
+# Best practices & troubleshooting
+
+## Do
+
+1. **Use `SafeCacheManager`** (or the typed wrapper) in application code โ not
+ `CacheManager` directly.
+2. **Set `SoftTTL` to 60โ80% of `HardTTL`** for a useful stale window.
+3. **Use 5โ10% jitter** to prevent synchronized TTL expirations.
+4. **Keep dependency lists small** (< ~10 items) and intentional โ each one adds
+ bookkeeping on `Set` and cascade work on `Invalidate`.
+5. **Use specific entity types** (`"project"`, `"user"`) over generic ones
+ (`"data"`, `"item"`).
+6. **Set `DisableRetry: true`** on the Valkey client so it fails fast instead of
+ queueing when the cache is down.
+7. **Declare dependencies completely** โ correctness relies on the fetcher
+ returning every entity a value depends on.
+8. **Prefer type-safe operations** (`GetSafe[T]` or `TypedSafeCacheManager[T]`).
+9. **Monitor cache metrics** via [hooks](observability.md) โ hit rate, latency,
+ fallback rate, refresh health.
+10. **Test against a real Valkey** in integration tests.
+
+## Don't
+
+1. **Don't cache everything** โ cache expensive or hot reads, not everything.
+2. **Don't set `SoftTTL == HardTTL`** โ it disables stale-while-revalidate.
+3. **Don't use a very high `QueryTimeout`** โ it defeats fast fallback.
+4. **Don't skip dependencies** โ a missing dependency means that class of change
+ never invalidates the value.
+5. **Don't forget to invalidate a collection key on inserts** โ see
+ [Limitations](limitations.md).
+6. **Don't disable jitter** โ it invites synchronized stampedes.
+7. **Don't point two managers with different encoders at the same key** โ a value
+ must be read back with the encoder that wrote it.
+
+## Troubleshooting
+
+### Cache always misses
+
+*High database load, hit rate near zero.*
+
+- Verify Valkey is running: `valkey-cli ping`.
+- Check the connection string / credentials.
+- Ensure `DisableRetry: true` is set on the client.
+- Check that TTLs are not too short.
+
+### Stale data served indefinitely
+
+*Background refreshes never seem to happen.*
+
+- Verify the fetcher isn't returning errors silently โ wire up
+ [`OnRefresh`](observability.md).
+- Check logs for `cache: background refresh failed`.
+- Ensure `SoftTTL < HardTTL` (otherwise there is no stale window).
+
+### Slow cache responses
+
+*High p99 latency, timeouts.*
+
+- Lower `QueryTimeout` (default 70ms) so slow lookups fall back sooner.
+- Reduce the dependency count per item.
+- Check Valkey server health and network latency.
+
+### Valkey memory growing
+
+- Verify TTLs are set (not zero).
+- Reduce `HardTTL` for less critical data.
+- Enable a Valkey eviction policy, e.g. `maxmemory-policy allkeys-lru`.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..32c91ae
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,51 @@
+# Configuration
+
+`SafeCacheManager` is configured with a `SafeCacheManagerConfig`:
+
+| Option | Type | Description | Default | Range |
+| --- | --- | --- | --- | --- |
+| **HardTTL** | `time.Duration` | Absolute expiration time | *required* | `> 0` |
+| **SoftTTL** | `time.Duration` | Age at which data is considered "stale" | *required* | `0 โ HardTTL` |
+| **JitterPercent** | `float64` | Max TTL jitter, as a fraction | *required* | `0.0 โ <1.0` |
+| **EncoderType** | `CacheEncoderType` | Serialization format | `JSON` | `JSON`, `Gob` |
+| **QueryTimeout** | `time.Duration` | Max cache lookup duration before falling back | `70ms` | `5ms โ 500ms` |
+| **Logger** | `*slog.Logger` | Logger for the manager | `slog.Default()` | โ |
+| **Hooks** | `Hooks` | Observability callbacks | zero value = no-op | โ |
+
+> The library validates only these **structural** invariants. Business-policy
+> bounds (for example "hard TTL must be 1โ72h") are the calling application's
+> responsibility โ validate your configuration before constructing the manager.
+
+The constructor returns an error if `HardTTL <= 0`, if `SoftTTL` is outside
+`[0, HardTTL]`, if `JitterPercent` is outside `[0.0, 1.0)`, or if a non-zero
+`QueryTimeout` falls outside `[5ms, 500ms]`. A zero `QueryTimeout` is replaced
+with the 70ms default.
+
+## Choosing values
+
+โ
**Do:**
+
+- Set `SoftTTL` to **60โ80% of `HardTTL`** for a useful stale-while-revalidate
+ window.
+- Use **5โ10% jitter** to break synchronized TTL expirations across replicas.
+- Keep `QueryTimeout` **low** (the 70ms default) so the cache never becomes a
+ latency bottleneck โ a slow lookup falls back to the source.
+- Set `DisableRetry: true` on the Valkey client so commands fail fast instead of
+ queueing when the cache is down.
+
+โ **Don't:**
+
+- Set `SoftTTL == HardTTL` โ that disables stale-while-revalidate (nothing is
+ ever served stale; expiry becomes a hard miss).
+- Use a very high `QueryTimeout` โ it defeats the fast-fallback design.
+
+## Encoder selection
+
+- **JSON** (default) โ interoperable and debuggable. Because the `CachedItem`
+ envelope stores the payload as `[]byte`, JSON values are base64-encoded inside
+ the wrapper (~33% size overhead).
+- **Gob** โ compact and fast for Go-only workloads. Not portable across
+ languages.
+
+A value must always be read back with the **same encoder** that wrote it. See
+[Limitations](limitations.md).
diff --git a/docs/limitations.md b/docs/limitations.md
new file mode 100644
index 0000000..9daf59c
--- /dev/null
+++ b/docs/limitations.md
@@ -0,0 +1,67 @@
+# Limitations & semantics
+
+Understanding these keeps you out of stale-data trouble โ especially with long
+TTLs, where a missed invalidation stays wrong for the full hard TTL.
+
+- **Invalidate collection keys on inserts.** The dependency graph only links
+ items *already in* a cached value. Adding a new item to a cached
+ list/aggregate is invisible to the graph โ you must invalidate the
+ collection's own key when membership changes (or accept staleness until TTL).
+ Updates and deletes of existing members cascade normally.
+- **Invalidation is downward-only.** Invalidating an entity drops the entries
+ that depend on it, not its own dependencies. For `Project โ depends on โ
+ User`: invalidating `User` drops `Project`; invalidating `Project` does not
+ touch `User`.
+- **Invalidation is eventually consistent, not atomic.** `Set` and the
+ `Invalidate` cascade run as pipelined commands, not a single transaction.
+ Under concurrency a dependent written during an in-flight cascade may be
+ missed; it self-heals at the next write or via TTL. Design for at-least-once
+ invalidation.
+- **Dependencies must be declared completely.** Correctness relies on the
+ fetcher returning *every* entity a value depends on. A missing dependency
+ means that class of change won't invalidate the value.
+- **Dependency metadata is TTL-bounded.** Reverse/forward dependency keys expire
+ with the entry TTL (refreshed on each write), so they can't leak when an entry
+ expires naturally. With heterogeneous per-key TTLs, the metadata tracks the
+ most recent write's TTL.
+- **Single instance / hash slot.** The dependency keys for one value span
+ multiple keys, and pipelined multi-key operations assume they are reachable
+ together. Running against Redis Cluster with arbitrary cross-entity
+ dependencies is not supported without key hash-tagging.
+- **One encoder per key.** A cached value must be read back with the same
+ encoder (JSON or Gob) that wrote it. Pointing two managers with different
+ encoders at the same key yields a decode error on read.
+- **JSON payloads are base64-wrapped.** The `CachedItem` envelope stores the
+ payload as `[]byte`, so JSON values are base64-encoded inside the wrapper
+ (~33% overhead). Use the Gob encoder for Go-only, size-sensitive workloads.
+
+## Error handling
+
+The package defines sentinel errors for predictable matching:
+
+```go
+var (
+ ErrCacheMiss = errors.New("cache miss")
+ ErrCommandExecution = errors.New("Valkey command execution failed")
+ ErrGetOldDependencies = errors.New("failed to get old dependencies")
+ ErrGetDependents = errors.New("failed to get dependents")
+)
+```
+
+Match them with `errors.Is`:
+
+```go
+err := cache.Get(ctx, identifier, &result, fetcher)
+if err != nil {
+ if errors.Is(err, c3e.ErrCacheMiss) {
+ // handle a cache miss specifically
+ } else {
+ // handle other errors
+ }
+}
+```
+
+`SafeCacheManager` automatically falls back to the fetcher (source of truth) on
+a cache **timeout**, **connection error**, **parse error**, or any other Valkey
+error โ so you rarely need to handle cache failures yourself. The outcome is
+still reported to the [`OnGet` hook](observability.md) as `timeout` or `error`.
diff --git a/docs/observability.md b/docs/observability.md
new file mode 100644
index 0000000..5f6836d
--- /dev/null
+++ b/docs/observability.md
@@ -0,0 +1,56 @@
+# Observability
+
+`c3e` exposes two seams โ an injectable logger and a set of hooks โ so you can
+wire in logging, metrics, and tracing **without wrapping** the manager.
+
+## Structured logging (injectable)
+
+The manager logs via `log/slog`. By default it uses `slog.Default()`; inject a
+scoped or silent logger through the config so the library never writes to your
+global logger unasked:
+
+```go
+cfg := c3e.SafeCacheManagerConfig{
+ HardTTL: time.Hour, SoftTTL: 40 * time.Minute, JitterPercent: 0.1,
+ Logger: slog.Default().With("component", "cache"), // or a discard handler to silence
+}
+```
+
+The resolved logger is propagated to the wrapped low-level `CacheManager`, so
+the whole stack logs through the same handler.
+
+## Metrics & tracing hooks
+
+Attach `Hooks` to observe every operation. Any nil callback is skipped, so the
+zero value is a no-op. **Callbacks must be non-blocking and safe for concurrent
+use** โ they run on the request path (and, for `OnRefresh`, on a background
+goroutine).
+
+```go
+cfg.Hooks = c3e.Hooks{
+ // Once per Get. result โ hit | stale | miss | timeout | error
+ OnGet: func(ctx context.Context, id c3e.CacheIdentifier, result c3e.Result, took time.Duration) {
+ cacheRequests.WithLabelValues(id.Type, string(result)).Inc()
+ cacheLatency.WithLabelValues(id.Type).Observe(took.Seconds())
+ },
+ // A background stale-while-revalidate refresh finished (err != nil on failure).
+ OnRefresh: func(ctx context.Context, id c3e.CacheIdentifier, err error) { /* ... */ },
+ // After an Invalidate (err != nil on failure).
+ OnInvalidate: func(ctx context.Context, id c3e.CacheIdentifier, took time.Duration, err error) { /* ... */ },
+}
+```
+
+### The `Result` enum and the golden signals
+
+`OnGet`'s `Result` maps directly to the cache signals worth alerting on:
+
+| `Result` | Meaning | Signal |
+| --- | --- | --- |
+| `hit` | served fresh from cache | **hit rate** = `hit` / total |
+| `stale` | served stale, refresh triggered | **stale-serve rate** |
+| `miss` | not cached; blocked on a fetch | part of **fallback rate** |
+| `timeout` | cache slower than `QueryTimeout` | part of **fallback rate** |
+| `error` | cache/network error | part of **fallback rate** |
+
+`took` feeds a **latency** histogram. `OnRefresh` errors surface
+background-refresh health (stale data that never manages to refresh).
diff --git a/docs/usage.md b/docs/usage.md
new file mode 100644
index 0000000..5235e1a
--- /dev/null
+++ b/docs/usage.md
@@ -0,0 +1,216 @@
+# Usage
+
+All snippets assume a constructed `*c3e.SafeCacheManager` (see the
+[root README](../README.md#quick-start) for setup).
+
+Runnable, godoc-rendered examples live in the `example_*_test.go` files and on
+[pkg.go.dev](https://pkg.go.dev/github.com/slashdevops/c3e#pkg-examples).
+
+## Basic cache usage
+
+```go
+type Project struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Owner string `json:"owner"`
+}
+
+func getProject(ctx context.Context, cache *c3e.SafeCacheManager, projectID string) (*Project, error) {
+ identifier := c3e.CacheIdentifier{Type: "project", ID: projectID}
+
+ var project Project
+ err := cache.Get(ctx, identifier, &project, func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ p, err := db.GetProject(ctx, projectID)
+ if err != nil {
+ return nil, nil, err
+ }
+ // Declare what this value depends on.
+ deps := []c3e.CacheIdentifier{
+ {Type: "user", ID: p.Owner}, // project depends on its owner
+ }
+ return p, deps, nil
+ })
+ return &project, err
+}
+```
+
+## Type-safe generic function (recommended โญ)
+
+`GetSafe[T]` gives you compile-time type safety without a wrapper type:
+
+```go
+func getProjectTypeSafe(ctx context.Context, cache *c3e.SafeCacheManager, projectID string) (Project, error) {
+ identifier := c3e.CacheIdentifier{Type: "project", ID: projectID}
+
+ return c3e.GetSafe(ctx, cache, identifier, func(ctx context.Context) (Project, []c3e.CacheIdentifier, error) {
+ p, err := db.GetProject(ctx, projectID)
+ if err != nil {
+ return Project{}, nil, err
+ }
+ deps := []c3e.CacheIdentifier{{Type: "user", ID: p.Owner}}
+ return p, deps, nil
+ })
+}
+```
+
+## TypedSafeCacheManager
+
+When you repeatedly Get/Invalidate one type, bind it once:
+
+```go
+projectCache := c3e.NewTypedSafeCacheManager[Project](safeCacheManager)
+
+identifier := c3e.CacheIdentifier{Type: "project", ID: "123"}
+project, err := projectCache.Get(ctx, identifier, func(ctx context.Context) (Project, []c3e.CacheIdentifier, error) {
+ p, err := db.GetProject(ctx, "123")
+ if err != nil {
+ return Project{}, nil, err
+ }
+ return p, []c3e.CacheIdentifier{{Type: "user", ID: p.Owner}}, nil
+})
+```
+
+## Dependency tracking & cascading invalidation
+
+```go
+// Cache a project that depends on several entities.
+identifier := c3e.CacheIdentifier{Type: "project", ID: "proj-123"}
+
+var project Project
+cache.Get(ctx, identifier, &project, func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ p, err := db.GetProject(ctx, "proj-123")
+ if err != nil {
+ return nil, nil, err
+ }
+ deps := []c3e.CacheIdentifier{
+ {Type: "user", ID: p.Owner}, // owner
+ {Type: "organization", ID: p.OrgID}, // organization
+ {Type: "team", ID: p.TeamID}, // team
+ }
+ return p, deps, nil
+})
+
+// Later โ when the owner changes, every dependent is dropped automatically.
+cache.Invalidate(ctx, c3e.CacheIdentifier{Type: "user", ID: "user-456"})
+```
+
+See [Limitations](limitations.md) for the exact invalidation contract (downward
+only, eventually consistent, complete-dependencies requirement).
+
+## GetOrFetch convenience function
+
+```go
+identifier := c3e.CacheIdentifier{Type: "settings", ID: "app-config"}
+
+settings, err := c3e.GetOrFetch(ctx, safeCacheManager, identifier,
+ func(ctx context.Context) (AppSettings, []c3e.CacheIdentifier, error) {
+ s, err := db.GetSettings(ctx)
+ return s, nil, err // no dependencies
+ })
+```
+
+## Per-request encoder
+
+`GetWithEncoder` / `SetWithEncoder` select JSON or Gob per call instead of using
+the manager's default. A value must always be read back with the same encoder
+that wrote it (see [Limitations](limitations.md)).
+
+```go
+cfg := c3e.SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ EncoderType: c3e.CacheEncoderTypeGob, // Gob is compact for Go-only structs
+}
+project, err := c3e.GetWithEncoder(ctx, cacheManager, cfg, identifier, fetchProject)
+```
+
+## Advanced patterns
+
+### Multi-level caching
+
+Cache an aggregate that depends on individually-cached items:
+
+```go
+func getCachedProjectSummary(ctx context.Context, projectID string) (Summary, error) {
+ identifier := c3e.CacheIdentifier{Type: "project-summary", ID: projectID}
+
+ return c3e.GetSafe(ctx, cache, identifier, func(ctx context.Context) (Summary, []c3e.CacheIdentifier, error) {
+ project, _ := getProject(ctx, projectID)
+ owner, _ := getUser(ctx, project.OwnerID)
+ stats, _ := getStats(ctx, projectID)
+
+ summary := Summary{Project: project, Owner: owner, Stats: stats}
+ deps := []c3e.CacheIdentifier{
+ {Type: "project", ID: projectID},
+ {Type: "user", ID: project.OwnerID},
+ {Type: "stats", ID: projectID},
+ }
+ return summary, deps, nil
+ })
+}
+```
+
+### Conditional caching
+
+Return `c3e.ErrCacheMiss` from the fetcher to skip caching a particular value:
+
+```go
+func getUser(ctx context.Context, userID string) (User, error) {
+ identifier := c3e.CacheIdentifier{Type: "user", ID: userID}
+
+ return c3e.GetSafe(ctx, cache, identifier, func(ctx context.Context) (User, []c3e.CacheIdentifier, error) {
+ user, err := db.GetUser(ctx, userID)
+ if err != nil {
+ return User{}, nil, err
+ }
+ if user.IsAdmin {
+ return user, nil, c3e.ErrCacheMiss // always fetch admins fresh
+ }
+ return user, nil, nil
+ })
+}
+```
+
+### Preemptive refresh
+
+```go
+// Force a refresh on the next Get.
+func refreshProjectCache(ctx context.Context, projectID string) error {
+ return cache.Invalidate(ctx, c3e.CacheIdentifier{Type: "project", ID: projectID})
+}
+```
+
+### Bulk invalidation
+
+```go
+func invalidateUserData(ctx context.Context, userID string) error {
+ ids := []c3e.CacheIdentifier{
+ {Type: "user", ID: userID},
+ {Type: "user-profile", ID: userID},
+ {Type: "user-settings", ID: userID},
+ {Type: "user-stats", ID: userID},
+ }
+ for _, id := range ids {
+ if err := cache.Invalidate(ctx, id); err != nil {
+ return fmt.Errorf("failed to invalidate %v: %w", id, err)
+ }
+ }
+ return nil
+}
+```
+
+## Testing
+
+Unit tests can mock the `CacheRepository` interface; integration tests run
+against a real Valkey. The suite in this repository skips the real-Valkey cases
+when none is reachable at `localhost:6379`, and the heavier end-to-end cases are
+guarded behind the `integration` build tag:
+
+```bash
+# Unit tests (no Valkey required)
+go test -race ./...
+
+# Full suite against a local Valkey
+docker run -d --rm -p 6379:6379 valkey/valkey:latest
+go test -race -tags=integration ./...
+```
diff --git a/example_integration_test.go b/example_integration_test.go
new file mode 100644
index 0000000..fefeb39
--- /dev/null
+++ b/example_integration_test.go
@@ -0,0 +1,300 @@
+//go:build integration
+
+package c3e_test
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "testing"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+
+ "github.com/slashdevops/c3e"
+)
+
+// TestCacheIntegration demonstrates a full integration test with Valkey
+// showing cache miss, cache hit, invalidation, and stale-while-revalidate scenarios.
+//
+// Run this test with: go test -tags=integration -run TestCacheIntegration ./pkg/c3e/
+func TestCacheIntegration(t *testing.T) {
+ ctx := context.Background()
+
+ // 1. Setup Valkey Client
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ t.Fatalf("Could not connect to Valkey: %v", err)
+ }
+ defer client.Close()
+
+ // Verify connection
+ pingCmd := client.B().Ping().Build()
+ if err := client.Do(ctx, pingCmd).Error(); err != nil {
+ t.Fatalf("Could not ping Valkey: %v", err)
+ }
+ log.Println("Connected to Valkey")
+
+ // Clear all for a clean test
+ flushCmd := client.B().Flushdb().Build()
+ if err := client.Do(ctx, flushCmd).Error(); err != nil {
+ t.Fatalf("Could not flush Valkey: %v", err)
+ }
+
+ // 2. Setup Cache Managers
+ coreMgr, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+ safeMgr, err := c3e.NewSafeCacheManager(coreMgr, c3e.SafeCacheManagerConfig{
+ HardTTL: 1 * time.Hour, // Long hard expiry
+ SoftTTL: 2 * time.Second, // Short soft expiry so the stale-while-revalidate case is exercised quickly
+ JitterPercent: 0.1, // 10% jitter
+ })
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ // 3. --- SCENARIO 1: Cache Miss (Slow) ---
+ t.Run("cache_miss_slow", func(t *testing.T) {
+ log.Println("\n--- 1. First Get (Cache Miss) ---")
+ start := time.Now()
+ var proj Project
+
+ // This is the fetcher function for project proj123
+ projectFetcher := func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ p, err := getProjectFromDB("proj123")
+ if err != nil {
+ return nil, nil, err
+ }
+ // Define dependencies
+ deps := []c3e.CacheIdentifier{
+ {Type: "user", ID: "user123"},
+ {Type: "user", ID: "user456"},
+ }
+ return p, deps, nil
+ }
+
+ err := safeMgr.Get(ctx, c3e.CacheIdentifier{Type: "project", ID: "proj123"}, &proj, projectFetcher)
+ if err != nil {
+ t.Fatalf("Error on first get: %v", err)
+ }
+
+ elapsed := time.Since(start)
+ log.Printf("SUCCESS: Got project: '%s'. Took: %s", proj.Name, elapsed)
+
+ if proj.Name != "Project 'Eagle'" {
+ t.Errorf("Expected project name 'Project 'Eagle'', got '%s'", proj.Name)
+ }
+
+ // Should take at least 500ms due to DB latency
+ if elapsed < 400*time.Millisecond {
+ t.Errorf("Expected cache miss to take at least 400ms, took %s", elapsed)
+ }
+ })
+
+ // 4. --- SCENARIO 2: Cache Hit (Fast) ---
+ t.Run("cache_hit_fast", func(t *testing.T) {
+ log.Println("\n--- 2. Second Get (Cache Hit) ---")
+ start := time.Now()
+ var proj2 Project
+
+ projectFetcher := func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ p, err := getProjectFromDB("proj123")
+ if err != nil {
+ return nil, nil, err
+ }
+ deps := []c3e.CacheIdentifier{
+ {Type: "user", ID: "user123"},
+ {Type: "user", ID: "user456"},
+ }
+ return p, deps, nil
+ }
+
+ err := safeMgr.Get(ctx, c3e.CacheIdentifier{Type: "project", ID: "proj123"}, &proj2, projectFetcher)
+ if err != nil {
+ t.Fatalf("Error on second get: %v", err)
+ }
+
+ elapsed := time.Since(start)
+ log.Printf("SUCCESS: Got project from cache: '%s'. Took: %s", proj2.Name, elapsed)
+
+ if proj2.Name != "Project 'Eagle'" {
+ t.Errorf("Expected project name 'Project 'Eagle'', got '%s'", proj2.Name)
+ }
+
+ // Cache hit should be very fast (< 50ms)
+ if elapsed > 50*time.Millisecond {
+ t.Errorf("Expected cache hit to be fast (<50ms), took %s", elapsed)
+ }
+ })
+
+ // 5. --- SCENARIO 3: Invalidation ---
+ t.Run("invalidation", func(t *testing.T) {
+ log.Println("\n--- 3. Invalidating 'user:user123' (a dependency) ---")
+ if err := safeMgr.Invalidate(ctx, c3e.CacheIdentifier{Type: "user", ID: "user123"}); err != nil {
+ t.Fatalf("Error on invalidate: %v", err)
+ }
+ log.Println("SUCCESS: Invalidated 'user:user123'. 'project:proj123' cache is now gone.")
+ })
+
+ // 6. --- SCENARIO 4: Post-Invalidation Miss (Slow) ---
+ t.Run("post_invalidation_miss", func(t *testing.T) {
+ log.Println("\n--- 4. Third Get (Post-Invalidation Miss) ---")
+ start := time.Now()
+ var proj3 Project
+
+ projectFetcher := func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ p, err := getProjectFromDB("proj123")
+ if err != nil {
+ return nil, nil, err
+ }
+ deps := []c3e.CacheIdentifier{
+ {Type: "user", ID: "user123"},
+ {Type: "user", ID: "user456"},
+ }
+ return p, deps, nil
+ }
+
+ err := safeMgr.Get(ctx, c3e.CacheIdentifier{Type: "project", ID: "proj123"}, &proj3, projectFetcher)
+ if err != nil {
+ t.Fatalf("Error on third get: %v", err)
+ }
+
+ elapsed := time.Since(start)
+ log.Printf("SUCCESS: Got project (re-fetched): '%s'. Took: %s", proj3.Name, elapsed)
+
+ if proj3.Name != "Project 'Eagle'" {
+ t.Errorf("Expected project name 'Project 'Eagle'', got '%s'", proj3.Name)
+ }
+
+ // Should take at least 500ms due to DB latency after invalidation
+ if elapsed < 400*time.Millisecond {
+ t.Errorf("Expected cache miss to take at least 400ms, took %s", elapsed)
+ }
+ })
+
+ // 7. --- SCENARIO 5: Stale-While-Revalidate ---
+ t.Run("stale_while_revalidate", func(t *testing.T) {
+ log.Println("\n--- 5. Stale-While-Revalidate Demo ---")
+ log.Println("Waiting for the soft TTL to pass so the entry becomes stale...")
+ time.Sleep(2500 * time.Millisecond)
+
+ start := time.Now()
+ var proj4 Project
+
+ projectFetcher := func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ p, err := getProjectFromDB("proj123")
+ if err != nil {
+ return nil, nil, err
+ }
+ deps := []c3e.CacheIdentifier{
+ {Type: "user", ID: "user123"},
+ {Type: "user", ID: "user456"},
+ }
+ return p, deps, nil
+ }
+
+ err := safeMgr.Get(ctx, c3e.CacheIdentifier{Type: "project", ID: "proj123"}, &proj4, projectFetcher)
+ if err != nil {
+ t.Fatalf("Error on stale get: %v", err)
+ }
+
+ elapsed := time.Since(start)
+ log.Printf("SUCCESS: Got STALE project: '%s'. Took: %s", proj4.Name, elapsed)
+ log.Println("(A background refresh was triggered. Check for 'DATABASE: Fetching...' log)")
+
+ if proj4.Name != "Project 'Eagle'" {
+ t.Errorf("Expected project name 'Project 'Eagle'', got '%s'", proj4.Name)
+ }
+
+ // Stale-while-revalidate should return quickly (stale data) even though it's expired
+ if elapsed > 50*time.Millisecond {
+ t.Logf("Note: Stale data returned in %s (background refresh triggered)", elapsed)
+ }
+
+ // Wait for the background refresh to complete
+ time.Sleep(1 * time.Second)
+ log.Println("Background refresh should have completed")
+ })
+}
+
+// Example_fullWorkflow demonstrates a complete cache workflow
+// This example requires a running Valkey instance on localhost:6379
+func Example_fullWorkflow() {
+ ctx := context.Background()
+
+ // Setup Valkey Client
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ // Setup Cache Managers
+ coreMgr, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ safeMgr, err := c3e.NewSafeCacheManager(coreMgr, c3e.SafeCacheManagerConfig{
+ HardTTL: 10 * time.Minute,
+ SoftTTL: 5 * time.Second,
+ JitterPercent: 0.1,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Define project fetcher
+ projectFetcher := func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ p, err := getProjectFromDB("proj123")
+ if err != nil {
+ return nil, nil, err
+ }
+ deps := []c3e.CacheIdentifier{
+ {Type: "user", ID: "user123"},
+ {Type: "user", ID: "user456"},
+ }
+ return p, deps, nil
+ }
+
+ // First call - cache miss (slow)
+ var proj1 Project
+ start := time.Now()
+ err = safeMgr.Get(ctx, c3e.CacheIdentifier{Type: "project", ID: "proj123"}, &proj1, projectFetcher)
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("First call: %s (took %s)\n", proj1.Name, time.Since(start))
+
+ // Second call - cache hit (fast)
+ var proj2 Project
+ start = time.Now()
+ err = safeMgr.Get(ctx, c3e.CacheIdentifier{Type: "project", ID: "proj123"}, &proj2, projectFetcher)
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("Second call: %s (took %s)\n", proj2.Name, time.Since(start))
+
+ // Invalidate dependency
+ err = safeMgr.Invalidate(ctx, c3e.CacheIdentifier{Type: "user", ID: "user123"})
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Println("Invalidated user:user123 (cascades to project)")
+
+ // Third call - cache miss after invalidation (slow)
+ var proj3 Project
+ start = time.Now()
+ err = safeMgr.Get(ctx, c3e.CacheIdentifier{Type: "project", ID: "proj123"}, &proj3, projectFetcher)
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("Third call after invalidation: %s (took %s)\n", proj3.Name, time.Since(start))
+}
diff --git a/example_test.go b/example_test.go
new file mode 100644
index 0000000..d6f5978
--- /dev/null
+++ b/example_test.go
@@ -0,0 +1,351 @@
+//go:build integration
+
+package c3e_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+
+ "github.com/slashdevops/c3e"
+)
+
+// User represents a sample domain entity
+type User struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// Project represents a project with associated users
+type Project struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ UserIDs []string `json:"user_ids"`
+}
+
+// --- Database simulation functions ---
+
+// getUserFromDB simulates a slow database call
+func getUserFromDB(id string) (*User, error) {
+ log.Printf("DATABASE: Fetching user %s...", id)
+ time.Sleep(500 * time.Millisecond) // Simulate DB latency
+
+ if id == "user123" {
+ return &User{
+ ID: "user123",
+ Name: "John Doe",
+ }, nil
+ }
+ return nil, fmt.Errorf("user not found")
+}
+
+// getProjectFromDB simulates a slow database call
+func getProjectFromDB(id string) (*Project, error) {
+ log.Printf("DATABASE: Fetching project %s...", id)
+ time.Sleep(500 * time.Millisecond) // Simulate DB latency
+
+ if id == "proj123" {
+ return &Project{
+ ID: "proj123",
+ Name: "Project 'Eagle'",
+ UserIDs: []string{"user123", "user456"},
+ }, nil
+ }
+ return nil, fmt.Errorf("project not found")
+}
+
+// ExampleSafeCacheManager demonstrates basic usage of the SafeCacheManager
+// with a real-world scenario of caching user data.
+func ExampleSafeCacheManager() {
+ // Initialize Valkey client
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ // Create cache manager
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Configure SafeCacheManager
+ config := c3e.SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour, // Cache expires after 2 hours
+ SoftTTL: 1 * time.Hour, // Start background refresh after 1 hour
+ JitterPercent: 0.1, // Add 10% jitter to prevent thundering herd
+ }
+ safeCacheManager, err := c3e.NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ ctx := context.Background()
+
+ // Define a fetcher function that loads user data from a database
+ fetchUser := func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ user, err := getUserFromDB("user123")
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // Define dependencies - this user might be part of a project
+ dependencies := []c3e.CacheIdentifier{{Type: "project", ID: "proj123"}}
+
+ return user, dependencies, nil
+ }
+
+ // Get user from cache or fetch from database
+ var user User
+ err = safeCacheManager.Get(ctx, c3e.CacheIdentifier{Type: "user", ID: "user123"}, &user, fetchUser)
+ if err != nil {
+ log.Printf("Error: %v", err)
+ return
+ }
+
+ fmt.Printf("User: %s\n", user.Name)
+ // Output: User: John Doe
+}
+
+// ExampleCacheManager_Set demonstrates setting a value in the cache with dependencies
+func ExampleCacheManager_Set() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ ctx := context.Background()
+
+ // Fetch user from database
+ user, err := getUserFromDB("user123")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Marshal to JSON
+ data, err := json.Marshal(user)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Set in cache with dependencies
+ identifier := c3e.CacheIdentifier{Type: "user", ID: "user123"}
+ err = cacheManager.Set(
+ ctx,
+ identifier,
+ data, // serialized data
+ []c3e.CacheIdentifier{ // dependencies
+ {Type: "project", ID: "proj123"},
+ },
+ 5*time.Minute, // TTL
+ )
+ if err != nil {
+ log.Printf("Error setting cache: %v", err)
+ return
+ }
+
+ fmt.Println("User cached successfully")
+ // Output: User cached successfully
+}
+
+// ExampleCacheManager_Get demonstrates retrieving a value from the cache
+func ExampleCacheManager_Get() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ ctx := context.Background()
+
+ // Get from cache
+ data, err := cacheManager.Get(ctx, c3e.CacheIdentifier{Type: "user", ID: "user123"}, 0)
+ if err == c3e.ErrCacheMiss {
+ fmt.Println("Cache miss - need to fetch from database")
+ return
+ }
+ if err != nil {
+ log.Printf("Error getting cache: %v", err)
+ return
+ }
+
+ // Unmarshal the data
+ var user User
+ if err := json.Unmarshal(data, &user); err != nil {
+ log.Printf("Error unmarshaling: %v", err)
+ return
+ }
+
+ fmt.Printf("User from cache: %s\n", user.Name)
+}
+
+// ExampleCacheManager_Invalidate demonstrates invalidating a cache entry
+func ExampleCacheManager_Invalidate() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ ctx := context.Background()
+
+ // Invalidate a project will cascade to all dependent entities
+ // (users, etc. that depend on this project)
+ err = cacheManager.Invalidate(ctx, c3e.CacheIdentifier{Type: "project", ID: "proj123"})
+ if err != nil {
+ log.Printf("Error invalidating cache: %v", err)
+ return
+ }
+
+ fmt.Println("Project and all dependents invalidated")
+ // Output: Project and all dependents invalidated
+}
+
+// ExampleSafeCacheManager_complexScenario demonstrates a more complex caching scenario
+// with nested dependencies and background refresh.
+func ExampleSafeCacheManager_complexScenario() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ config := c3e.SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour, // Min: 1 hour, Max: 72 hours
+ SoftTTL: 30 * time.Minute, // Min: 1 minute, Max: HardTTL
+ JitterPercent: 0.15,
+ }
+ safeCacheManager, err := c3e.NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ ctx := context.Background()
+
+ // Fetch project with user dependencies
+ fetchProject := func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ project, err := getProjectFromDB("proj123")
+ if err != nil {
+ return nil, nil, err
+ }
+
+ // This project depends on its users
+ dependencies := []c3e.CacheIdentifier{
+ {Type: "user", ID: "user123"},
+ {Type: "user", ID: "user456"},
+ }
+
+ return project, dependencies, nil
+ }
+
+ // Get project - will use cache if fresh, serve stale and refresh if needed
+ var project Project
+ err = safeCacheManager.Get(ctx, c3e.CacheIdentifier{Type: "project", ID: "proj123"}, &project, fetchProject)
+ if err != nil {
+ log.Printf("Error: %v", err)
+ return
+ }
+
+ fmt.Printf("Project loaded: %s with %d users\n", project.Name, len(project.UserIDs))
+ // Output: Project loaded: Project 'Eagle' with 2 users
+}
+
+// ExampleSafeCacheManager_staleWhileRevalidate demonstrates the stale-while-revalidate behavior
+func ExampleSafeCacheManager_staleWhileRevalidate() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ config := c3e.SafeCacheManagerConfig{
+ HardTTL: 5 * time.Minute,
+ SoftTTL: 2 * time.Minute, // After 2 minutes, trigger background refresh
+ JitterPercent: 0.1,
+ }
+ safeCacheManager, err := c3e.NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ ctx := context.Background()
+
+ fetchCounter := 0
+ fetchData := func(ctx context.Context) (any, []c3e.CacheIdentifier, error) {
+ fetchCounter++
+ data := map[string]any{
+ "value": fmt.Sprintf("data-%d", fetchCounter),
+ "fetched_at": time.Now().Unix(),
+ }
+ return data, nil, nil
+ }
+
+ // First call - cache miss, fetches from source
+ var data1 map[string]any
+ _ = safeCacheManager.Get(ctx, c3e.CacheIdentifier{Type: "data", ID: "key1"}, &data1, fetchData)
+ fmt.Printf("First call fetch count: %d\n", fetchCounter)
+
+ // Simulate time passing beyond softTTL
+ time.Sleep(2100 * time.Millisecond)
+
+ // Second call - returns stale data immediately, triggers background refresh
+ var data2 map[string]any
+ _ = safeCacheManager.Get(ctx, c3e.CacheIdentifier{Type: "data", ID: "key1"}, &data2, fetchData)
+ fmt.Printf("Second call (stale): value=%s\n", data2["value"])
+
+ // Give background refresh time to complete
+ time.Sleep(100 * time.Millisecond)
+
+ // Third call - gets the refreshed data
+ var data3 map[string]any
+ _ = safeCacheManager.Get(ctx, c3e.CacheIdentifier{Type: "data", ID: "key1"}, &data3, fetchData)
+ fmt.Printf("Third call (refreshed): value=%s\n", data3["value"])
+ fmt.Printf("Total fetches: %d\n", fetchCounter)
+
+ // Note: The stale-while-revalidate pattern means we only blocked once,
+ // subsequent calls got immediate responses while refresh happened in background
+}
diff --git a/example_typed_test.go b/example_typed_test.go
new file mode 100644
index 0000000..fcc93a4
--- /dev/null
+++ b/example_typed_test.go
@@ -0,0 +1,313 @@
+//go:build integration
+
+package c3e_test
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+
+ "github.com/slashdevops/c3e"
+)
+
+// ExampleTypedSafeCacheManager demonstrates using the type-safe generic wrapper
+func ExampleTypedSafeCacheManager() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ // Create base managers
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ safeMgr, err := c3e.NewSafeCacheManager(cacheManager, c3e.SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1, // 10% jitter
+ EncoderType: c3e.CacheEncoderTypeJSON,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Create typed manager for User
+ userCache := c3e.NewTypedSafeCacheManager[User](safeMgr)
+
+ ctx := context.Background()
+
+ // Define typed fetcher
+ fetchUser := func(ctx context.Context) (User, []c3e.CacheIdentifier, error) {
+ user, err := getUserFromDB("user123")
+ if err != nil {
+ return User{}, nil, err
+ }
+ deps := []c3e.CacheIdentifier{{Type: "project", ID: "proj123"}}
+ return *user, deps, nil
+ }
+
+ // Get user with type safety
+ user, err := userCache.Get(ctx, c3e.CacheIdentifier{Type: "user", ID: "user123"}, fetchUser)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("User: %s\n", user.Name)
+ // Output: User: John Doe
+}
+
+// ExampleTypedSafeCacheManager_GetOrFetch demonstrates using the GetOrFetch method on the typed manager
+func ExampleTypedSafeCacheManager_GetOrFetch() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ // Create base managers
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ safeMgr, err := c3e.NewSafeCacheManager(cacheManager, c3e.SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ EncoderType: c3e.CacheEncoderTypeJSON,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Create typed manager
+ userCache := c3e.NewTypedSafeCacheManager[User](safeMgr)
+ ctx := context.Background()
+
+ // Use GetOrFetch method
+ userIdentifier := c3e.CacheIdentifier{Type: "user", ID: "user123"}
+ user, err := userCache.GetOrFetch(
+ ctx,
+ userIdentifier,
+ func(ctx context.Context) (User, []c3e.CacheIdentifier, error) {
+ u, err := getUserFromDB("user123")
+ if err != nil {
+ return User{}, nil, err
+ }
+ return *u, []c3e.CacheIdentifier{{Type: "project", ID: "proj123"}}, nil
+ },
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("User: %s\n", user.Name)
+ // Output: User: John Doe
+}
+
+// ExampleGetSafe demonstrates the standalone generic GetSafe function
+func ExampleGetSafe() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ // Create base managers
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ safeMgr, err := c3e.NewSafeCacheManager(cacheManager, c3e.SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ EncoderType: c3e.CacheEncoderTypeJSON,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ ctx := context.Background()
+
+ // Define typed fetcher
+ fetchUser := func(ctx context.Context) (User, []c3e.CacheIdentifier, error) {
+ user, err := getUserFromDB("user123")
+ if err != nil {
+ return User{}, nil, err
+ }
+ deps := []c3e.CacheIdentifier{{Type: "project", ID: "proj123"}}
+ return *user, deps, nil
+ }
+
+ // Get user with type safety using standalone function
+ user, err := c3e.GetSafe(ctx, safeMgr, c3e.CacheIdentifier{Type: "user", ID: "user123"}, fetchUser)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("User: %s\n", user.Name)
+ // Output: User: John Doe
+}
+
+// ExampleGetOrFetch demonstrates the convenience GetOrFetch function
+func ExampleGetOrFetch() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ safeCacheManager, err := c3e.NewSafeCacheManager(cacheManager, c3e.SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1, // 10% jitter
+ EncoderType: c3e.CacheEncoderTypeJSON,
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ ctx := context.Background()
+
+ // Use GetOrFetch helper
+ userIdentifier := c3e.CacheIdentifier{Type: "user", ID: "user123"}
+ user, err := c3e.GetOrFetch(
+ ctx,
+ safeCacheManager,
+ userIdentifier,
+ func(ctx context.Context) (User, []c3e.CacheIdentifier, error) {
+ u, err := getUserFromDB("user123")
+ if err != nil {
+ return User{}, nil, err
+ }
+ return *u, []c3e.CacheIdentifier{{Type: "project", ID: "proj123"}}, nil
+ },
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("User: %s\n", user.Name)
+ // Output: User: John Doe
+}
+
+// ExampleSetWithEncoder demonstrates explicit encoder selection for Set operations
+func ExampleSetWithEncoder() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ ctx := context.Background()
+
+ user := User{
+ ID: "user789",
+ Name: "Jane Smith",
+ }
+
+ // Set with JSON encoding
+ userIdentifier := c3e.CacheIdentifier{Type: "user", ID: "user789"}
+ err = c3e.SetWithEncoder(
+ ctx,
+ cacheManager,
+ c3e.CacheEncoderTypeJSON,
+ userIdentifier,
+ user,
+ []c3e.CacheIdentifier{{Type: "project", ID: "proj456"}},
+ 2*time.Hour,
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Println("User cached with JSON encoding")
+ // Output: User cached with JSON encoding
+}
+
+// ExampleGetWithEncoder demonstrates per-request encoder selection
+func ExampleGetWithEncoder() {
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer client.Close()
+
+ cacheManager, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ log.Fatal(err)
+ }
+ ctx := context.Background()
+
+ // Use Gob encoding for this specific request
+ config := c3e.SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ EncoderType: c3e.CacheEncoderTypeGob, // Use Gob for better performance
+ }
+
+ fetchProject := func(ctx context.Context) (Project, []c3e.CacheIdentifier, error) {
+ proj, err := getProjectFromDB("proj123")
+ if err != nil {
+ return Project{}, nil, err
+ }
+ return *proj, []c3e.CacheIdentifier{{Type: "user", ID: "user123"}, {Type: "user", ID: "user456"}}, nil
+ }
+
+ projectIdentifier := c3e.CacheIdentifier{Type: "project", ID: "proj123"}
+
+ // Start from a clean slate: an entry cached earlier with a different
+ // encoder would otherwise be decoded with this request's Gob decoder.
+ // A cached value must always be read back with the same encoder that
+ // wrote it (see the "Limitations" docs); invalidating first guarantees
+ // this example is self-contained.
+ if err := cacheManager.Invalidate(ctx, projectIdentifier); err != nil {
+ log.Fatal(err)
+ }
+
+ project, err := c3e.GetWithEncoder(
+ ctx,
+ cacheManager,
+ config,
+ projectIdentifier,
+ fetchProject,
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("Project: %s (using Gob encoding)\n", project.Name)
+ // Output: Project: Project 'Eagle' (using Gob encoding)
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..70437c5
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,10 @@
+module github.com/slashdevops/c3e
+
+go 1.26.0
+
+require (
+ github.com/valkey-io/valkey-go v1.0.76
+ golang.org/x/sync v0.22.0
+)
+
+require golang.org/x/sys v0.43.0 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..4d09a06
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,16 @@
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
+github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
+github.com/valkey-io/valkey-go v1.0.76 h1:Rcown7FFseVhG9b0+4MWfMs4xWu8otPzHjrsK044ET4=
+github.com/valkey-io/valkey-go v1.0.76/go.mod h1:6X581PhgfeMkJmyfjIsa2eFdq6dy3Qkkg9zwjM1p42M=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
diff --git a/helpers.go b/helpers.go
new file mode 100644
index 0000000..3a31672
--- /dev/null
+++ b/helpers.go
@@ -0,0 +1,157 @@
+package c3e
+
+import (
+ "bytes"
+ "encoding/gob"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/rand"
+ "strings"
+ "time"
+)
+
+// Cache-specific errors
+var (
+ // ErrCacheMiss is a specific error returned when an item is not in the cache.
+ ErrCacheMiss = errors.New("cache: item not found")
+
+ // ErrCommandExecution is returned when a cache command fails to execute.
+ ErrCommandExecution = errors.New("cache: command execution failed")
+
+ // ErrGetOldDependencies is returned when fetching old dependencies fails.
+ ErrGetOldDependencies = errors.New("cache: failed to get old dependencies")
+
+ // ErrGetDependents is returned when fetching dependents fails.
+ ErrGetDependents = errors.New("cache: failed to get dependents")
+)
+
+// CacheEncoderType is the type of encoder to use when encoding and decoding values in cache
+type CacheEncoderType string
+
+const (
+ // CacheEncoderTypeJSON uses the standard encoding/json package.
+ // This is the default and produces human-readable cache values.
+ CacheEncoderTypeJSON CacheEncoderType = "json"
+
+ // CacheEncoderTypeGob uses the encoding/gob package.
+ // This is more efficient for Go-specific types but less portable.
+ CacheEncoderTypeGob CacheEncoderType = "gob"
+)
+
+func (e CacheEncoderType) String() string {
+ return string(e)
+}
+
+// CacheIdentifier represents a cache entity with its type and ID.
+// This is used to uniquely identify cached items.
+// Example:
+//
+// CacheIdentifier{Type: "project", ID: "123"}
+// CacheIdentifier{Type: "user", ID: "44b1dfa5-b15e-4f03-944f-cbf61dfca144"}
+type CacheIdentifier struct {
+ Type string
+ ID string
+}
+
+// String returns the string representation of the CacheIdentifier.
+// e.g., "project:123"
+func (e CacheIdentifier) String() string {
+ return fmt.Sprintf("%s:%s", e.Type, e.ID)
+}
+
+// cacheKey generates the key for the actual cached data.
+// e.g., "cache:project:123"
+func cacheKey(identifier CacheIdentifier) string {
+ return fmt.Sprintf("cache:%s:%s", identifier.Type, identifier.ID)
+}
+
+// depKey generates the key for the reverse dependency Set.
+// e.g., "dep:user:456"
+func depKey(identifier CacheIdentifier) string {
+ return fmt.Sprintf("dep:%s:%s", identifier.Type, identifier.ID)
+}
+
+// depsForKey generates the key for the forward dependency Set.
+// e.g., "deps-for:cache:project:123"
+func depsForKey(cKey string) string { return fmt.Sprintf("deps-for:%s", cKey) }
+
+// entityKeyFromDepKey converts a dep key back into an entity key.
+// e.g., "dep:project:123" -> "project:123"
+func entityKeyFromDepKey(dKey string) string {
+ return strings.TrimPrefix(dKey, "dep:")
+}
+
+// depKeyFromCacheKey converts a cache key into its corresponding dep key.
+// e.g., "cache:project:123" -> "dep:project:123"
+func depKeyFromCacheKey(cKey string) string {
+ return strings.Replace(cKey, "cache:", "dep:", 1)
+}
+
+// CachedItem is the wrapper struct we store in Valkey to manage stale-while-revalidate.
+type CachedItem struct {
+ // Data holds the serialized object.
+ // We use []byte instead of json.RawMessage to ensure safe marshaling of both
+ // JSON (text) and Gob (binary) data. This means the data will be base64 encoded
+ // in the JSON wrapper, which adds some overhead but guarantees correctness.
+ Data []byte `json:"data"`
+
+ // RefreshAt is the Unix timestamp when this item becomes "stale"
+ RefreshAt int64 `json:"refresh_at"`
+}
+
+// jitterTTL applies a random +/- jitter to a base TTL.
+func jitterTTL(baseTTL time.Duration, jitterPercent float64) time.Duration {
+ if jitterPercent <= 0 || baseTTL == 0 {
+ return baseTTL
+ }
+
+ jitter := float64(baseTTL) * jitterPercent
+ randomJitter := (rand.Float64() * 2) - 1 // -1.0 to +1.0
+
+ finalJitter := time.Duration(randomJitter * jitter)
+ finalTTL := baseTTL + finalJitter
+
+ // Ensure it's at least a reasonable minimum (e.g., 1s)
+ if finalTTL < time.Second {
+ return time.Second
+ }
+
+ return finalTTL
+}
+
+// EncodeJSON encodes a value to JSON bytes
+func EncodeJSON[T any](value T) ([]byte, error) {
+ return json.Marshal(value)
+}
+
+// DecodeJSON decodes JSON bytes into a value
+func DecodeJSON[T any](data []byte) (T, error) {
+ var value T
+ if err := json.Unmarshal(data, &value); err != nil {
+ return value, err
+ }
+
+ return value, nil
+}
+
+// EncodeGob encodes a value to Gob bytes
+func EncodeGob[T any](value T) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := gob.NewEncoder(&buf).Encode(value); err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+// DecodeGob decodes Gob bytes into a value
+func DecodeGob[T any](data []byte) (T, error) {
+ var value T
+ buf := bytes.NewReader(data)
+ if err := gob.NewDecoder(buf).Decode(&value); err != nil {
+ return value, err
+ }
+
+ return value, nil
+}
diff --git a/helpers_test.go b/helpers_test.go
new file mode 100644
index 0000000..ffe6efd
--- /dev/null
+++ b/helpers_test.go
@@ -0,0 +1,480 @@
+package c3e
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+)
+
+func TestJitterTTL(t *testing.T) {
+ tests := []struct {
+ name string
+ baseTTL time.Duration
+ jitterPercent float64
+ expectRange bool
+ }{
+ {
+ name: "no_jitter",
+ baseTTL: time.Minute,
+ jitterPercent: 0,
+ expectRange: false,
+ },
+ {
+ name: "10_percent_jitter",
+ baseTTL: time.Minute,
+ jitterPercent: 0.1,
+ expectRange: true,
+ },
+ {
+ name: "50_percent_jitter",
+ baseTTL: time.Minute,
+ jitterPercent: 0.5,
+ expectRange: true,
+ },
+ {
+ name: "zero_base_ttl",
+ baseTTL: 0,
+ jitterPercent: 0.1,
+ expectRange: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := jitterTTL(tt.baseTTL, tt.jitterPercent)
+
+ // Check minimum TTL enforcement
+ if tt.baseTTL > 0 && result < time.Second {
+ t.Errorf("expected result >= 1s, got %v", result)
+ }
+
+ if tt.expectRange {
+ // With jitter, result should vary from baseTTL
+ minExpected := tt.baseTTL - time.Duration(float64(tt.baseTTL)*tt.jitterPercent)
+ maxExpected := tt.baseTTL + time.Duration(float64(tt.baseTTL)*tt.jitterPercent)
+
+ if result < minExpected || result > maxExpected {
+ t.Logf("result %v is outside expected range [%v, %v]", result, minExpected, maxExpected)
+ // Note: Due to randomness, this might occasionally be outside range
+ // This is informational, not a hard failure
+ }
+ } else {
+ // No jitter or zero TTL
+ if tt.baseTTL == 0 {
+ if result != 0 {
+ t.Errorf("expected 0, got %v", result)
+ }
+ } else {
+ if result != tt.baseTTL && result >= time.Second {
+ t.Errorf("expected %v, got %v", tt.baseTTL, result)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestJitterTTL_Distribution(t *testing.T) {
+ // Test that jitter produces varied results
+ baseTTL := 10 * time.Second
+ jitterPercent := 0.2 // 20%
+
+ results := make(map[time.Duration]bool)
+ iterations := 100
+
+ for range iterations {
+ result := jitterTTL(baseTTL, jitterPercent)
+ results[result] = true
+ }
+
+ // We expect at least some variation (not all the same)
+ if len(results) < 2 {
+ t.Error("expected jitter to produce varied results")
+ }
+
+ t.Logf("Generated %d unique TTL values from %d iterations", len(results), iterations)
+}
+
+func TestJitterTTL_MinimumEnforcement(t *testing.T) {
+ // Test that very small base TTLs are enforced to at least 1 second
+ tests := []struct {
+ name string
+ baseTTL time.Duration
+ }{
+ {"100ms", 100 * time.Millisecond},
+ {"500ms", 500 * time.Millisecond},
+ {"900ms", 900 * time.Millisecond},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := jitterTTL(tt.baseTTL, 0.1)
+ if result < time.Second {
+ t.Errorf("expected minimum 1s, got %v", result)
+ }
+ })
+ }
+}
+
+func TestCachedItem_Serialization(t *testing.T) {
+ now := time.Now()
+ item := CachedItem{
+ Data: json.RawMessage(`{"key":"value"}`),
+ RefreshAt: now.Unix(),
+ }
+
+ // Test JSON marshaling
+ data, err := json.Marshal(item)
+ if err != nil {
+ t.Fatalf("failed to marshal CachedItem: %v", err)
+ }
+
+ // Test JSON unmarshaling
+ var decoded CachedItem
+ err = json.Unmarshal(data, &decoded)
+ if err != nil {
+ t.Fatalf("failed to unmarshal CachedItem: %v", err)
+ }
+
+ // Verify fields
+ if decoded.RefreshAt != item.RefreshAt {
+ t.Errorf("expected RefreshAt %d, got %d", item.RefreshAt, decoded.RefreshAt)
+ }
+
+ if string(decoded.Data) != string(item.Data) {
+ t.Errorf("expected Data %s, got %s", item.Data, decoded.Data)
+ }
+}
+
+func TestCachedItem_WithComplexData(t *testing.T) {
+ type ComplexData struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Tags []string `json:"tags"`
+ CreatedAt time.Time `json:"created_at"`
+ }
+
+ now := time.Now()
+ complexData := ComplexData{
+ ID: 123,
+ Name: "Test Object",
+ Tags: []string{"tag1", "tag2", "tag3"},
+ CreatedAt: now,
+ }
+
+ // Serialize complex data
+ serialized, err := json.Marshal(complexData)
+ if err != nil {
+ t.Fatalf("failed to marshal complex data: %v", err)
+ }
+
+ // Create cached item
+ item := CachedItem{
+ Data: serialized,
+ RefreshAt: now.Unix(),
+ }
+
+ // Marshal cached item
+ itemData, err := json.Marshal(item)
+ if err != nil {
+ t.Fatalf("failed to marshal CachedItem: %v", err)
+ }
+
+ // Unmarshal cached item
+ var decodedItem CachedItem
+ err = json.Unmarshal(itemData, &decodedItem)
+ if err != nil {
+ t.Fatalf("failed to unmarshal CachedItem: %v", err)
+ }
+
+ // Unmarshal complex data from item
+ var decodedComplex ComplexData
+ err = json.Unmarshal(decodedItem.Data, &decodedComplex)
+ if err != nil {
+ t.Fatalf("failed to unmarshal complex data: %v", err)
+ }
+
+ // Verify fields
+ if decodedComplex.ID != complexData.ID {
+ t.Errorf("expected ID %d, got %d", complexData.ID, decodedComplex.ID)
+ }
+
+ if decodedComplex.Name != complexData.Name {
+ t.Errorf("expected Name %s, got %s", complexData.Name, decodedComplex.Name)
+ }
+
+ if len(decodedComplex.Tags) != len(complexData.Tags) {
+ t.Errorf("expected %d tags, got %d", len(complexData.Tags), len(decodedComplex.Tags))
+ }
+}
+
+func TestErrCacheMiss(t *testing.T) {
+ if ErrCacheMiss == nil {
+ t.Error("ErrCacheMiss should not be nil")
+ }
+
+ if ErrCacheMiss.Error() != "cache: item not found" {
+ t.Errorf("unexpected error message: %s", ErrCacheMiss.Error())
+ }
+}
+
+func TestErrCommandExecution(t *testing.T) {
+ if ErrCommandExecution == nil {
+ t.Error("ErrCommandExecution should not be nil")
+ }
+
+ if ErrCommandExecution.Error() != "cache: command execution failed" {
+ t.Errorf("unexpected error message: %s", ErrCommandExecution.Error())
+ }
+}
+
+func TestErrGetOldDependencies(t *testing.T) {
+ if ErrGetOldDependencies == nil {
+ t.Error("ErrGetOldDependencies should not be nil")
+ }
+
+ if ErrGetOldDependencies.Error() != "cache: failed to get old dependencies" {
+ t.Errorf("unexpected error message: %s", ErrGetOldDependencies.Error())
+ }
+}
+
+func TestErrGetDependents(t *testing.T) {
+ if ErrGetDependents == nil {
+ t.Error("ErrGetDependents should not be nil")
+ }
+
+ if ErrGetDependents.Error() != "cache: failed to get dependents" {
+ t.Errorf("unexpected error message: %s", ErrGetDependents.Error())
+ }
+}
+
+func TestCacheEncoderType_String(t *testing.T) {
+ tests := []struct {
+ name string
+ encoder CacheEncoderType
+ expected string
+ }{
+ {"json", CacheEncoderTypeJSON, "json"},
+ {"gob", CacheEncoderTypeGob, "gob"},
+ {"custom", CacheEncoderType("custom"), "custom"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.encoder.String() != tt.expected {
+ t.Errorf("expected %q, got %q", tt.expected, tt.encoder.String())
+ }
+ })
+ }
+}
+
+func TestCacheIdentifier_String(t *testing.T) {
+ tests := []struct {
+ name string
+ identifier CacheIdentifier
+ expected string
+ }{
+ {"simple", CacheIdentifier{Type: "user", ID: "123"}, "user:123"},
+ {"uuid_id", CacheIdentifier{Type: "project", ID: "44b1dfa5-b15e-4f03-944f-cbf61dfca144"}, "project:44b1dfa5-b15e-4f03-944f-cbf61dfca144"},
+ {"empty_id", CacheIdentifier{Type: "user", ID: ""}, "user:"},
+ {"empty_type", CacheIdentifier{Type: "", ID: "123"}, ":123"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.identifier.String() != tt.expected {
+ t.Errorf("expected %q, got %q", tt.expected, tt.identifier.String())
+ }
+ })
+ }
+}
+
+func TestEncodeDecodeJSON(t *testing.T) {
+ type sample struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ }
+
+ t.Run("round_trip", func(t *testing.T) {
+ original := sample{ID: 42, Name: "test"}
+ encoded, err := EncodeJSON(original)
+ if err != nil {
+ t.Fatalf("EncodeJSON failed: %v", err)
+ }
+
+ decoded, err := DecodeJSON[sample](encoded)
+ if err != nil {
+ t.Fatalf("DecodeJSON failed: %v", err)
+ }
+
+ if decoded.ID != original.ID || decoded.Name != original.Name {
+ t.Errorf("expected %+v, got %+v", original, decoded)
+ }
+ })
+
+ t.Run("encode_string", func(t *testing.T) {
+ encoded, err := EncodeJSON("hello")
+ if err != nil {
+ t.Fatalf("EncodeJSON failed: %v", err)
+ }
+
+ decoded, err := DecodeJSON[string](encoded)
+ if err != nil {
+ t.Fatalf("DecodeJSON failed: %v", err)
+ }
+
+ if decoded != "hello" {
+ t.Errorf("expected %q, got %q", "hello", decoded)
+ }
+ })
+
+ t.Run("encode_slice", func(t *testing.T) {
+ original := []int{1, 2, 3}
+ encoded, err := EncodeJSON(original)
+ if err != nil {
+ t.Fatalf("EncodeJSON failed: %v", err)
+ }
+
+ decoded, err := DecodeJSON[[]int](encoded)
+ if err != nil {
+ t.Fatalf("DecodeJSON failed: %v", err)
+ }
+
+ if len(decoded) != len(original) {
+ t.Errorf("expected %d items, got %d", len(original), len(decoded))
+ }
+ })
+
+ t.Run("decode_invalid_json", func(t *testing.T) {
+ _, err := DecodeJSON[sample]([]byte("not json"))
+ if err == nil {
+ t.Error("expected error for invalid JSON")
+ }
+ })
+
+ t.Run("encode_nil_map", func(t *testing.T) {
+ var m map[string]string
+ encoded, err := EncodeJSON(m)
+ if err != nil {
+ t.Fatalf("EncodeJSON failed: %v", err)
+ }
+
+ if string(encoded) != "null" {
+ t.Errorf("expected null, got %s", encoded)
+ }
+ })
+}
+
+func TestEncodeDecodeGob(t *testing.T) {
+ type sample struct {
+ ID int
+ Name string
+ }
+
+ t.Run("round_trip", func(t *testing.T) {
+ original := sample{ID: 42, Name: "test"}
+ encoded, err := EncodeGob(original)
+ if err != nil {
+ t.Fatalf("EncodeGob failed: %v", err)
+ }
+
+ decoded, err := DecodeGob[sample](encoded)
+ if err != nil {
+ t.Fatalf("DecodeGob failed: %v", err)
+ }
+
+ if decoded.ID != original.ID || decoded.Name != original.Name {
+ t.Errorf("expected %+v, got %+v", original, decoded)
+ }
+ })
+
+ t.Run("encode_string", func(t *testing.T) {
+ encoded, err := EncodeGob("hello gob")
+ if err != nil {
+ t.Fatalf("EncodeGob failed: %v", err)
+ }
+
+ decoded, err := DecodeGob[string](encoded)
+ if err != nil {
+ t.Fatalf("DecodeGob failed: %v", err)
+ }
+
+ if decoded != "hello gob" {
+ t.Errorf("expected %q, got %q", "hello gob", decoded)
+ }
+ })
+
+ t.Run("encode_int_slice", func(t *testing.T) {
+ original := []int{10, 20, 30}
+ encoded, err := EncodeGob(original)
+ if err != nil {
+ t.Fatalf("EncodeGob failed: %v", err)
+ }
+
+ decoded, err := DecodeGob[[]int](encoded)
+ if err != nil {
+ t.Fatalf("DecodeGob failed: %v", err)
+ }
+
+ if len(decoded) != len(original) {
+ t.Errorf("expected %d items, got %d", len(original), len(decoded))
+ }
+ })
+
+ t.Run("decode_invalid_data", func(t *testing.T) {
+ _, err := DecodeGob[sample]([]byte("not gob"))
+ if err == nil {
+ t.Error("expected error for invalid Gob data")
+ }
+ })
+}
+
+func TestJitterTTL_NegativeJitterPercent(t *testing.T) {
+ result := jitterTTL(time.Minute, -0.5)
+ if result != time.Minute {
+ t.Errorf("expected %v for negative jitter, got %v", time.Minute, result)
+ }
+}
+
+// Benchmark tests for helper functions
+func BenchmarkJitterTTL(b *testing.B) {
+ baseTTL := 5 * time.Minute
+ jitterPercent := 0.1
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ jitterTTL(baseTTL, jitterPercent)
+ }
+}
+
+func BenchmarkCachedItem_Marshal(b *testing.B) {
+ item := CachedItem{
+ Data: json.RawMessage(`{"key":"value","nested":{"foo":"bar"}}`),
+ RefreshAt: time.Now().Unix(),
+ }
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, err := json.Marshal(item)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
+
+func BenchmarkCachedItem_Unmarshal(b *testing.B) {
+ item := CachedItem{
+ Data: json.RawMessage(`{"key":"value","nested":{"foo":"bar"}}`),
+ RefreshAt: time.Now().Unix(),
+ }
+ data, _ := json.Marshal(item)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ var decoded CachedItem
+ err := json.Unmarshal(data, &decoded)
+ if err != nil {
+ b.Fatal(err)
+ }
+ }
+}
diff --git a/hooks.go b/hooks.go
new file mode 100644
index 0000000..5d49f6b
--- /dev/null
+++ b/hooks.go
@@ -0,0 +1,61 @@
+package c3e
+
+import (
+ "context"
+ "time"
+)
+
+// Result is the outcome of a SafeCacheManager.Get, reported to Hooks.OnGet.
+type Result string
+
+const (
+ // ResultHit: served fresh from the cache.
+ ResultHit Result = "hit"
+ // ResultStale: served stale data; a background refresh was triggered.
+ ResultStale Result = "stale"
+ // ResultMiss: not in cache (or corrupt); value fetched from the source.
+ ResultMiss Result = "miss"
+ // ResultTimeout: the cache did not respond within QueryTimeout; fell back
+ // to the source.
+ ResultTimeout Result = "timeout"
+ // ResultError: a cache error occurred; fell back to the source.
+ ResultError Result = "error"
+)
+
+// Hooks are optional observability callbacks invoked on cache events. Any nil
+// field is skipped, so a zero Hooks is a no-op.
+//
+// Callbacks MUST be safe for concurrent use and MUST NOT block โ do cheap
+// metric/log work or hand off to a channel. They let a caller emit metrics or
+// traces (e.g. cache hit ratio, latency, refresh health) without wrapping the
+// manager.
+type Hooks struct {
+ // OnGet is invoked once per Get with the cache outcome and elapsed time.
+ OnGet func(ctx context.Context, id CacheIdentifier, result Result, took time.Duration)
+
+ // OnRefresh is invoked when a background stale-while-revalidate refresh
+ // finishes; err is non-nil if the refresh failed.
+ OnRefresh func(ctx context.Context, id CacheIdentifier, err error)
+
+ // OnInvalidate is invoked after an Invalidate completes; err is non-nil on
+ // failure.
+ OnInvalidate func(ctx context.Context, id CacheIdentifier, took time.Duration, err error)
+}
+
+func (h Hooks) onGet(ctx context.Context, id CacheIdentifier, r Result, took time.Duration) {
+ if h.OnGet != nil {
+ h.OnGet(ctx, id, r, took)
+ }
+}
+
+func (h Hooks) onRefresh(ctx context.Context, id CacheIdentifier, err error) {
+ if h.OnRefresh != nil {
+ h.OnRefresh(ctx, id, err)
+ }
+}
+
+func (h Hooks) onInvalidate(ctx context.Context, id CacheIdentifier, took time.Duration, err error) {
+ if h.OnInvalidate != nil {
+ h.OnInvalidate(ctx, id, took, err)
+ }
+}
diff --git a/hooks_test.go b/hooks_test.go
new file mode 100644
index 0000000..896c9c2
--- /dev/null
+++ b/hooks_test.go
@@ -0,0 +1,109 @@
+package c3e
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestHooks_OnGet_calledOnFallback(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Skip("Skipping: Valkey not available to build a real command builder")
+ }
+ }()
+
+ cm, err := NewCacheManager(newMockCacheRepository(), false)
+ if err != nil {
+ t.Fatalf("NewCacheManager: %v", err)
+ }
+
+ var (
+ calls int
+ gotResult Result
+ gotID CacheIdentifier
+ gotElapsed bool
+ )
+ cfg := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ Hooks: Hooks{
+ OnGet: func(_ context.Context, id CacheIdentifier, r Result, took time.Duration) {
+ calls++
+ gotResult = r
+ gotID = id
+ gotElapsed = took >= 0
+ },
+ },
+ }
+ sm, err := NewSafeCacheManager(cm, cfg)
+ if err != nil {
+ t.Fatalf("NewSafeCacheManager: %v", err)
+ }
+
+ fetcher := func(_ context.Context) (any, []CacheIdentifier, error) {
+ return testData{ID: 1, Name: "from source"}, nil, nil
+ }
+
+ id := CacheIdentifier{Type: "thing", ID: "1"}
+ var dst testData
+ if err := sm.Get(context.Background(), id, &dst, fetcher); err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+
+ if calls != 1 {
+ t.Fatalf("OnGet called %d times, want 1", calls)
+ }
+ if gotID != id {
+ t.Errorf("OnGet id = %v, want %v", gotID, id)
+ }
+ if gotResult == "" {
+ t.Error("OnGet result is empty")
+ }
+ if !gotElapsed {
+ t.Error("OnGet elapsed not reported")
+ }
+}
+
+func TestHooks_OnInvalidate_called(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Skip("Skipping: Valkey not available to build a real command builder")
+ }
+ }()
+
+ cm, err := NewCacheManager(newMockCacheRepository(), false)
+ if err != nil {
+ t.Fatalf("NewCacheManager: %v", err)
+ }
+
+ called := false
+ cfg := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ Hooks: Hooks{
+ OnInvalidate: func(_ context.Context, _ CacheIdentifier, _ time.Duration, _ error) {
+ called = true
+ },
+ },
+ }
+ sm, err := NewSafeCacheManager(cm, cfg)
+ if err != nil {
+ t.Fatalf("NewSafeCacheManager: %v", err)
+ }
+
+ _ = sm.Invalidate(context.Background(), CacheIdentifier{Type: "thing", ID: "1"})
+ if !called {
+ t.Error("OnInvalidate was not called")
+ }
+}
+
+func TestHooks_zeroValueIsNoop(t *testing.T) {
+ // A zero Hooks must not panic when invoked.
+ var h Hooks
+ h.onGet(context.Background(), CacheIdentifier{Type: "t", ID: "1"}, ResultHit, time.Millisecond)
+ h.onRefresh(context.Background(), CacheIdentifier{Type: "t", ID: "1"}, nil)
+ h.onInvalidate(context.Background(), CacheIdentifier{Type: "t", ID: "1"}, time.Millisecond, nil)
+}
diff --git a/invalidation_cascade_test.go b/invalidation_cascade_test.go
new file mode 100644
index 0000000..c46cf63
--- /dev/null
+++ b/invalidation_cascade_test.go
@@ -0,0 +1,499 @@
+//go:build integration
+
+package c3e_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+
+ "github.com/slashdevops/c3e"
+)
+
+// Test entities for real-world dependency scenario
+type Policy struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+type Role struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ PolicyID string `json:"policy_id"`
+}
+
+type Permission struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ PolicyID string `json:"policy_id"`
+ RoleID string `json:"role_id"`
+ UserID string `json:"user_id"`
+}
+
+type Model struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ProjectID string `json:"project_id"`
+ LLMEnginesID string `json:"llm_engine_id,omitempty"`
+}
+
+type LLMEngine struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// TestCascadingInvalidation_RealWorldScenario tests the real-world scenario with multiple entities
+// and complex dependency relationships as described in the requirements.
+//
+// Dependency Graph:
+// - Policy (no dependencies)
+// - Role depends on Policy
+// - User (no dependencies)
+// - Project depends on User
+// - Model depends on Project (and optionally LLM Engine)
+// - Permission depends on Policy, Role, User
+// - LLM Engine (no dependencies)
+//
+// Run this test with: go test -tags=integration -run TestCascadingInvalidation_RealWorldScenario ./pkg/c3e/
+func TestCascadingInvalidation_RealWorldScenario(t *testing.T) {
+ ctx := context.Background()
+
+ // Setup Valkey Client
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ t.Fatalf("Could not connect to Valkey: %v", err)
+ }
+ defer client.Close()
+
+ // Verify connection
+ pingCmd := client.B().Ping().Build()
+ if err := client.Do(ctx, pingCmd).Error(); err != nil {
+ t.Fatalf("Could not ping Valkey: %v", err)
+ }
+
+ // Clear all for a clean test
+ flushCmd := client.B().Flushdb().Build()
+ if err := client.Do(ctx, flushCmd).Error(); err != nil {
+ t.Fatalf("Could not flush Valkey: %v", err)
+ }
+
+ // Setup Cache Managers
+ coreMgr, err := c3e.NewCacheManager(client, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ // --- Step 1: Cache Policy (no dependencies) ---
+ t.Run("cache_policy", func(t *testing.T) {
+ policy := Policy{ID: "policy1", Name: "Admin Policy"}
+ err := c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "policy", ID: "policy1"},
+ nil, // no dependencies
+ policy,
+ 5*time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("Failed to cache policy: %v", err)
+ }
+ t.Log("โ Cached Policy (no dependencies)")
+ })
+
+ // --- Step 2: Cache Role (depends on Policy) ---
+ t.Run("cache_role", func(t *testing.T) {
+ role := Role{ID: "role1", Name: "Admin Role", PolicyID: "policy1"}
+ err := c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "role", ID: "role1"},
+ []c3e.CacheIdentifier{
+ {Type: "policy", ID: "policy1"},
+ },
+ role,
+ 5*time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("Failed to cache role: %v", err)
+ }
+ t.Log("โ Cached Role (depends on Policy)")
+ })
+
+ // --- Step 3: Cache User (no dependencies) ---
+ t.Run("cache_user", func(t *testing.T) {
+ user := User{ID: "user1", Name: "John Doe"}
+ err := c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "user", ID: "user1"},
+ nil, // no dependencies
+ user,
+ 5*time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("Failed to cache user: %v", err)
+ }
+ t.Log("โ Cached User (no dependencies)")
+ })
+
+ // --- Step 4: Cache Project (depends on User) ---
+ t.Run("cache_project", func(t *testing.T) {
+ project := Project{ID: "project1", Name: "AI Project", UserIDs: []string{"user1"}}
+ err := c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "project", ID: "project1"},
+ []c3e.CacheIdentifier{
+ {Type: "user", ID: "user1"},
+ },
+ project,
+ 5*time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("Failed to cache project: %v", err)
+ }
+ t.Log("โ Cached Project (depends on User)")
+ })
+
+ // --- Step 5: Cache Model #1 (depends on Project) ---
+ t.Run("cache_model1", func(t *testing.T) {
+ model := Model{ID: "model1", Name: "GPT Model", ProjectID: "project1"}
+ err := c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "model", ID: "model1"},
+ []c3e.CacheIdentifier{
+ {Type: "project", ID: "project1"},
+ },
+ model,
+ 5*time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("Failed to cache model1: %v", err)
+ }
+ t.Log("โ Cached Model #1 (depends on Project)")
+ })
+
+ // --- Step 6: Cache Permission (depends on Policy, Role, User) ---
+ t.Run("cache_permission", func(t *testing.T) {
+ permission := Permission{
+ ID: "perm1",
+ Name: "Read Access",
+ PolicyID: "policy1",
+ RoleID: "role1",
+ UserID: "user1",
+ }
+ err := c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "permission", ID: "perm1"},
+ []c3e.CacheIdentifier{
+ {Type: "policy", ID: "policy1"},
+ {Type: "role", ID: "role1"},
+ {Type: "user", ID: "user1"},
+ },
+ permission,
+ 5*time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("Failed to cache permission: %v", err)
+ }
+ t.Log("โ Cached Permission (depends on Policy, Role, User)")
+ })
+
+ // --- Step 7: Cache Model #2 (depends on Project) ---
+ t.Run("cache_model2", func(t *testing.T) {
+ model := Model{ID: "model2", Name: "BERT Model", ProjectID: "project1"}
+ err := c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "model", ID: "model2"},
+ []c3e.CacheIdentifier{
+ {Type: "project", ID: "project1"},
+ },
+ model,
+ 5*time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("Failed to cache model2: %v", err)
+ }
+ t.Log("โ Cached Model #2 (depends on Project)")
+ })
+
+ // --- Step 8: Cache LLM Engine (no dependencies) ---
+ t.Run("cache_llm_engine", func(t *testing.T) {
+ engine := LLMEngine{ID: "engine1", Name: "OpenAI Engine"}
+ err := c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "llm_engine", ID: "engine1"},
+ nil, // no dependencies
+ engine,
+ 5*time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("Failed to cache llm_engine: %v", err)
+ }
+ t.Log("โ Cached LLM Engine (no dependencies)")
+ })
+
+ // --- Step 9: Cache Model #3 (depends on Project and LLM Engine) ---
+ t.Run("cache_model3", func(t *testing.T) {
+ model := Model{
+ ID: "model3",
+ Name: "Custom LLM Model",
+ ProjectID: "project1",
+ LLMEnginesID: "engine1",
+ }
+ err := c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "model", ID: "model3"},
+ []c3e.CacheIdentifier{
+ {Type: "project", ID: "project1"},
+ {Type: "llm_engine", ID: "engine1"},
+ },
+ model,
+ 5*time.Minute,
+ )
+ if err != nil {
+ t.Fatalf("Failed to cache model3: %v", err)
+ }
+ t.Log("โ Cached Model #3 (depends on Project and LLM Engine)")
+ })
+
+ // --- SCENARIO 1: Invalidate User ---
+ // Expected: Should invalidate Project, all Models (1, 2, 3), and Permission
+ t.Run("invalidate_user_cascade", func(t *testing.T) {
+ t.Log("\n=== SCENARIO 1: Invalidating User ===")
+
+ // Verify all items exist before invalidation
+ verifyExists := func(entityType, id string) {
+ _, err := c3e.Get[any](ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: entityType, ID: id}, 0)
+ if err != nil {
+ t.Fatalf("Expected %s:%s to exist, but got error: %v", entityType, id, err)
+ }
+ }
+
+ t.Log("Verifying all items exist before invalidation...")
+ verifyExists("policy", "policy1")
+ verifyExists("role", "role1")
+ verifyExists("user", "user1")
+ verifyExists("project", "project1")
+ verifyExists("model", "model1")
+ verifyExists("model", "model2")
+ verifyExists("model", "model3")
+ verifyExists("permission", "perm1")
+ verifyExists("llm_engine", "engine1")
+ t.Log("โ All items confirmed to exist")
+
+ // Invalidate User
+ t.Log("Invalidating user:user1...")
+ err := coreMgr.Invalidate(ctx, c3e.CacheIdentifier{Type: "user", ID: "user1"})
+ if err != nil {
+ t.Fatalf("Failed to invalidate user: %v", err)
+ }
+
+ // Verify expected items are invalidated
+ verifyInvalidated := func(entityType, id string) {
+ _, err := c3e.Get[any](ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: entityType, ID: id}, 0)
+ if err != c3e.ErrCacheMiss {
+ t.Errorf("Expected %s:%s to be invalidated, but got: %v", entityType, id, err)
+ }
+ }
+
+ // Verify still exists
+ verifyStillExists := func(entityType, id string) {
+ _, err := c3e.Get[any](ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: entityType, ID: id}, 0)
+ if err != nil {
+ t.Errorf("Expected %s:%s to still exist, but got error: %v", entityType, id, err)
+ }
+ }
+
+ t.Log("\nVerifying cascade invalidation results:")
+ t.Log(" Should be INVALIDATED:")
+ verifyInvalidated("user", "user1")
+ t.Log(" โ user:user1")
+ verifyInvalidated("project", "project1")
+ t.Log(" โ project:project1")
+ verifyInvalidated("model", "model1")
+ t.Log(" โ model:model1")
+ verifyInvalidated("model", "model2")
+ t.Log(" โ model:model2")
+ verifyInvalidated("model", "model3")
+ t.Log(" โ model:model3")
+ verifyInvalidated("permission", "perm1")
+ t.Log(" โ permission:perm1")
+
+ t.Log(" Should STILL EXIST:")
+ verifyStillExists("policy", "policy1")
+ t.Log(" โ policy:policy1")
+ verifyStillExists("role", "role1")
+ t.Log(" โ role:role1")
+ verifyStillExists("llm_engine", "engine1")
+ t.Log(" โ llm_engine:engine1")
+
+ t.Log("\nโ
SCENARIO 1 PASSED: User invalidation correctly cascaded to dependents")
+ })
+
+ // --- Re-populate cache for Scenario 2 ---
+ t.Run("repopulate_cache", func(t *testing.T) {
+ t.Log("\n=== Repopulating cache for Scenario 2 ===")
+
+ // Re-cache only what was invalidated
+ user := User{ID: "user1", Name: "John Doe"}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "user", ID: "user1"}, nil, user, 5*time.Minute)
+
+ project := Project{ID: "project1", Name: "AI Project", UserIDs: []string{"user1"}}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "project", ID: "project1"},
+ []c3e.CacheIdentifier{{Type: "user", ID: "user1"}},
+ project, 5*time.Minute)
+
+ model1 := Model{ID: "model1", Name: "GPT Model", ProjectID: "project1"}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "model", ID: "model1"},
+ []c3e.CacheIdentifier{{Type: "project", ID: "project1"}},
+ model1, 5*time.Minute)
+
+ model2 := Model{ID: "model2", Name: "BERT Model", ProjectID: "project1"}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "model", ID: "model2"},
+ []c3e.CacheIdentifier{{Type: "project", ID: "project1"}},
+ model2, 5*time.Minute)
+
+ model3 := Model{ID: "model3", Name: "Custom LLM Model", ProjectID: "project1", LLMEnginesID: "engine1"}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "model", ID: "model3"},
+ []c3e.CacheIdentifier{
+ {Type: "project", ID: "project1"},
+ {Type: "llm_engine", ID: "engine1"},
+ },
+ model3, 5*time.Minute)
+
+ permission := Permission{ID: "perm1", Name: "Read Access", PolicyID: "policy1", RoleID: "role1", UserID: "user1"}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "permission", ID: "perm1"},
+ []c3e.CacheIdentifier{
+ {Type: "policy", ID: "policy1"},
+ {Type: "role", ID: "role1"},
+ {Type: "user", ID: "user1"},
+ },
+ permission, 5*time.Minute)
+
+ t.Log("โ Cache repopulated")
+ })
+
+ // --- SCENARIO 2: Invalidate LLM Engine ---
+ // Expected: Should invalidate only Model #3
+ t.Run("invalidate_llm_engine_cascade", func(t *testing.T) {
+ t.Log("\n=== SCENARIO 2: Invalidating LLM Engine ===")
+
+ // Invalidate LLM Engine
+ t.Log("Invalidating llm_engine:engine1...")
+ err := coreMgr.Invalidate(ctx, c3e.CacheIdentifier{Type: "llm_engine", ID: "engine1"})
+ if err != nil {
+ t.Fatalf("Failed to invalidate llm_engine: %v", err)
+ }
+
+ // Verify expected items are invalidated
+ verifyInvalidated := func(entityType, id string) {
+ _, err := c3e.Get[any](ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: entityType, ID: id}, 0)
+ if err != c3e.ErrCacheMiss {
+ t.Errorf("Expected %s:%s to be invalidated, but got: %v", entityType, id, err)
+ }
+ }
+
+ // Verify still exists
+ verifyStillExists := func(entityType, id string) {
+ _, err := c3e.Get[any](ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: entityType, ID: id}, 0)
+ if err != nil {
+ t.Errorf("Expected %s:%s to still exist, but got error: %v", entityType, id, err)
+ }
+ }
+
+ t.Log("\nVerifying cascade invalidation results:")
+ t.Log(" Should be INVALIDATED:")
+ verifyInvalidated("llm_engine", "engine1")
+ t.Log(" โ llm_engine:engine1")
+ verifyInvalidated("model", "model3")
+ t.Log(" โ model:model3 (depends on LLM Engine)")
+
+ t.Log(" Should STILL EXIST:")
+ verifyStillExists("policy", "policy1")
+ t.Log(" โ policy:policy1")
+ verifyStillExists("role", "role1")
+ t.Log(" โ role:role1")
+ verifyStillExists("user", "user1")
+ t.Log(" โ user:user1")
+ verifyStillExists("project", "project1")
+ t.Log(" โ project:project1")
+ verifyStillExists("model", "model1")
+ t.Log(" โ model:model1")
+ verifyStillExists("model", "model2")
+ t.Log(" โ model:model2")
+ verifyStillExists("permission", "perm1")
+ t.Log(" โ permission:perm1")
+
+ t.Log("\nโ
SCENARIO 2 PASSED: LLM Engine invalidation correctly cascaded only to model3")
+ })
+}
+
+// Example_realWorldCascade demonstrates the cascading invalidation with a visual output
+func Example_realWorldCascade() {
+ ctx := context.Background()
+
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ return
+ }
+ defer client.Close()
+
+ // Flush for clean state
+ flushCmd := client.B().Flushdb().Build()
+ client.Do(ctx, flushCmd)
+
+ coreMgr, _ := c3e.NewCacheManager(client, false)
+
+ // Build the dependency graph
+ fmt.Println("Building cache with dependencies...")
+
+ // Cache entities with dependencies
+ policy := Policy{ID: "policy1", Name: "Admin Policy"}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "policy", ID: "policy1"}, nil, policy, 5*time.Minute)
+
+ role := Role{ID: "role1", Name: "Admin Role", PolicyID: "policy1"}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "role", ID: "role1"},
+ []c3e.CacheIdentifier{{Type: "policy", ID: "policy1"}},
+ role, 5*time.Minute)
+
+ user := User{ID: "user1", Name: "John"}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "user", ID: "user1"}, nil, user, 5*time.Minute)
+
+ project := Project{ID: "project1", Name: "AI Project", UserIDs: []string{"user1"}}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "project", ID: "project1"},
+ []c3e.CacheIdentifier{{Type: "user", ID: "user1"}},
+ project, 5*time.Minute)
+
+ model := Model{ID: "model1", Name: "GPT Model", ProjectID: "project1"}
+ c3e.Set(ctx, coreMgr, c3e.CacheEncoderTypeJSON,
+ c3e.CacheIdentifier{Type: "model", ID: "model1"},
+ []c3e.CacheIdentifier{{Type: "project", ID: "project1"}},
+ model, 5*time.Minute)
+
+ fmt.Println("Cache built successfully")
+ fmt.Println("\nInvalidating user:user1...")
+
+ // Invalidate user - should cascade to project and model
+ coreMgr.Invalidate(ctx, c3e.CacheIdentifier{Type: "user", ID: "user1"})
+
+ fmt.Println("Cascade complete: user โ project โ model")
+ // Output:
+ // Building cache with dependencies...
+ // Cache built successfully
+ //
+ // Invalidating user:user1...
+ // Cascade complete: user โ project โ model
+}
diff --git a/manager.go b/manager.go
new file mode 100644
index 0000000..3adab54
--- /dev/null
+++ b/manager.go
@@ -0,0 +1,411 @@
+package c3e
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+)
+
+// CacheRepository defines the interface for Valkey operations used by CacheManager.
+// This uses an interface to allow for easier testing and mocking since you can implement dependency injection.
+type CacheRepository interface {
+ // Do executes a single command.
+ Do(ctx context.Context, cmd valkey.Completed) (resp valkey.ValkeyResult)
+ // DoMulti executes multiple commands in a pipeline/batch.
+ DoMulti(ctx context.Context, multi ...valkey.Completed) (resps []valkey.ValkeyResult)
+ // DoCache executes a single command with server-assisted client-side caching.
+ DoCache(ctx context.Context, cmd valkey.Cacheable, ttl time.Duration) (resp valkey.ValkeyResult)
+ // DoMultiCache executes multiple commands with server-assisted client-side caching.
+ DoMultiCache(ctx context.Context, multi ...valkey.CacheableTTL) (resps []valkey.ValkeyResult)
+ // B returns a command builder.
+ B() valkey.Builder
+}
+
+// CacheManager handles the low-level Valkey operations and dependency tracking.
+// It does not implement any high-level policies like stale-while-revalidate or thundering herd protection.
+// Those are implemented in SafeCacheManager.
+type CacheManager struct {
+ client CacheRepository
+ logger *slog.Logger
+ clientCacheEnabled bool
+}
+
+// NewCacheManager creates a new core cache manager.
+// Parameters:
+// - client: Valkey client/repository to use
+// - clientCacheEnabled: whether to enable server-assisted client-side caching via DoCache
+//
+// The manager logs through slog.Default(). Inject a scoped (or silent) logger
+// via SafeCacheManagerConfig.Logger when constructing a SafeCacheManager.
+func NewCacheManager(client CacheRepository, clientCacheEnabled bool) (*CacheManager, error) {
+ if client == nil {
+ return nil, fmt.Errorf("client cannot be nil")
+ }
+
+ return &CacheManager{
+ client: client,
+ logger: slog.Default(),
+ clientCacheEnabled: clientCacheEnabled,
+ }, nil
+}
+
+// Set caches an item and registers its dependencies.
+// It performs the following operations in a batch:
+// 1. Removes the item from old dependencies (if updated).
+// 2. Adds the item to new dependencies.
+// 3. Sets the item data with the specified TTL.
+//
+// Parameters:
+// - ctx: Context for the operation
+// - identifier: The unique identifier for the item being cached
+// - data: The pre-serialized []byte of the CachedItem wrapper
+// - dependencies: A list of CacheIdentifiers representing dependencies
+// - ttl: Time-to-live for the cache entry
+func (m *CacheManager) Set(ctx context.Context, identifier CacheIdentifier, data []byte, dependencies []CacheIdentifier, ttl time.Duration) error {
+ cKey := cacheKey(identifier)
+ dKey := depsForKey(cKey)
+
+ // Create the list of full dependency keys, e.g., "dep:user:123"
+ newDepKeys := make([]string, len(dependencies))
+ for i, dep := range dependencies {
+ newDepKeys[i] = depKey(dep)
+ }
+
+ // --- 1. Get old dependencies ---
+ // The forward-dependency set is mutable metadata: always read it fresh, even
+ // when client-side caching is enabled. A stale (client-cached) read here
+ // would compute the wrong "old" dependency set and leave orphaned entries in
+ // the reverse-dependency sets.
+ builder := m.client.B()
+ oldDepKeysResult := m.client.Do(ctx, builder.Smembers().Key(dKey).Build())
+
+ oldDepKeys, err := oldDepKeysResult.AsStrSlice()
+ if err != nil && !valkey.IsValkeyNil(err) {
+ return fmt.Errorf("%w: %w", ErrGetOldDependencies, err)
+ }
+
+ // If key doesn't exist, AsStrSlice returns error but we treat it as empty list
+ if valkey.IsValkeyNil(err) {
+ oldDepKeys = []string{}
+ }
+
+ // Build a map of new dependencies for quick lookup
+ newDepsMap := make(map[string]bool, len(newDepKeys))
+ for _, k := range newDepKeys {
+ newDepsMap[k] = true
+ }
+
+ // --- 2. Build commands for batch execution ---
+ cmds := make([]valkey.Completed, 0)
+
+ // The dependency-tracking keys (reverse-dep sets and the forward-dep list)
+ // are given the same TTL as the cache entry. Otherwise they are SADD'ed with
+ // no expiry and, when a cache entry expires naturally instead of being
+ // explicitly invalidated, its membership in these sets would linger forever
+ // โ an unbounded memory leak in Valkey. Each Set refreshes the TTL, so sets
+ // that still back a live dependent stay alive while genuinely orphaned ones
+ // expire.
+ ttlSecs := int64(ttl.Seconds())
+
+ // Remove this cache key from any reverse-deps it no longer needs
+ for _, oldKey := range oldDepKeys {
+ if !newDepsMap[oldKey] {
+ cmds = append(cmds, builder.Srem().Key(oldKey).Member(cKey).Build())
+ }
+ }
+
+ // Add new dependencies (reverse-dep sets), each bounded by the entry TTL
+ for _, newKey := range newDepKeys {
+ cmds = append(cmds, builder.Sadd().Key(newKey).Member(cKey).Build())
+ cmds = append(cmds, builder.Expire().Key(newKey).Seconds(ttlSecs).Build())
+ }
+
+ // Set the cached data with TTL
+ cmds = append(cmds, builder.Set().Key(cKey).Value(valkey.BinaryString(data)).ExSeconds(ttlSecs).Build())
+
+ // Delete old forward-dep list
+ cmds = append(cmds, builder.Del().Key(dKey).Build())
+
+ // Add new forward-dep list if there are dependencies, bounded by the entry TTL
+ if len(newDepKeys) > 0 {
+ cmds = append(cmds, builder.Sadd().Key(dKey).Member(newDepKeys...).Build())
+ cmds = append(cmds, builder.Expire().Key(dKey).Seconds(ttlSecs).Build())
+ }
+
+ // --- 3. Execute all commands in a batch ---
+ results := m.client.DoMulti(ctx, cmds...)
+
+ // Check for errors in any of the results
+ for i, result := range results {
+ if err := result.Error(); err != nil {
+ return fmt.Errorf("%w: command %d failed: %w", ErrCommandExecution, i, err)
+ }
+ }
+
+ return nil
+}
+
+// Get performs a raw get from Valkey.
+// It returns the serialized []byte of the CachedItem wrapper.
+// Returns valkey.Nil if the key does not exist.
+func (m *CacheManager) Get(ctx context.Context, identifier CacheIdentifier, ttl time.Duration) ([]byte, error) {
+ cKey := cacheKey(identifier)
+
+ builder := m.client.B()
+ var result valkey.ValkeyResult
+
+ if m.clientCacheEnabled {
+ getCmd := builder.Get().Key(cKey).Cache()
+ result = m.client.DoCache(ctx, getCmd, ttl)
+ } else {
+ getCmd := builder.Get().Key(cKey).Build()
+ result = m.client.Do(ctx, getCmd)
+ }
+
+ val, err := result.ToString()
+ if err != nil {
+ if valkey.IsValkeyNil(err) {
+ return nil, ErrCacheMiss
+ }
+
+ return nil, fmt.Errorf("%w: %w", ErrCommandExecution, err)
+ }
+
+ return []byte(val), nil
+}
+
+// Invalidate performs a cascading invalidation for an entity.
+// When an entity (e.g., "user:123") is changed or deleted, this function will:
+// 1. Delete its own cache entry (cache:user:123).
+// 2. Clean up the forward dependencies (deps-for:cache:user:123) by removing this item
+// from the reverse dependency sets of items it depends on.
+// 3. Find all cache keys that depend on this entity (read dep:user:123).
+// 4. For each dependent, recursively cascade the invalidation downstream.
+// 5. Delete all reverse-dependency sets encountered during the cascade.
+//
+// This function ONLY cascades DOWN the dependency tree (to dependents), not up.
+// Example: If Project depends on User, invalidating User will cascade to Project,
+// but invalidating Project will NOT cascade to User.
+func (m *CacheManager) Invalidate(ctx context.Context, identifier CacheIdentifier) error {
+ // Use a queue for breadth-first invalidation (safer than recursion)
+ itemKey := cacheKey(identifier)
+ queue := []string{depKey(identifier)}
+ visited := make(map[string]bool)
+ builder := m.client.B()
+
+ // Collect all deletion commands
+ delCommands := make([]valkey.Completed, 0)
+
+ m.logger.Debug("cache: starting invalidation", "entity_key", identifier.String())
+
+ // --- Step 1: Clean up the root item's forward dependencies ---
+ // Remove the root item from the reverse-dependency sets of items it depends on.
+ // This prevents stale references when the root item is deleted.
+ rootDepsKey := depsForKey(itemKey)
+ rootDepsResult := m.client.Do(ctx, builder.Smembers().Key(rootDepsKey).Build())
+ if rootDeps, err := rootDepsResult.AsStrSlice(); err == nil {
+ for _, dep := range rootDeps {
+ // Remove this cache key from the reverse dependency set
+ delCommands = append(delCommands, builder.Srem().Key(dep).Member(itemKey).Build())
+ }
+ }
+
+ // Delete the forward-dependency list for the root item
+ delCommands = append(delCommands, builder.Del().Key(rootDepsKey).Build())
+
+ // Delete the cache entry for the root item
+ delCommands = append(delCommands, builder.Del().Key(itemKey).Build())
+
+ // --- Step 2: Cascade invalidation to all dependents (downstream) ---
+ for len(queue) > 0 {
+ // Pop from queue
+ currentDepKey := queue[0]
+ queue = queue[1:]
+
+ if visited[currentDepKey] {
+ continue
+ }
+
+ visited[currentDepKey] = true
+
+ // Find all cache keys that depend on this entity (reverse dependencies)
+ smembersCmd := builder.Smembers().Key(currentDepKey).Build()
+ result := m.client.Do(ctx, smembersCmd)
+ dependents, err := result.AsStrSlice()
+ m.logger.Debug("cache: invalidation step", "current_dep_key", currentDepKey, "dependents_found", len(dependents))
+
+ if err != nil && !valkey.IsValkeyNil(err) {
+ return fmt.Errorf("%w for key %s: %w", ErrGetDependents, currentDepKey, err)
+ }
+
+ // If key doesn't exist, treat as empty list
+ if valkey.IsValkeyNil(err) {
+ dependents = []string{}
+ }
+
+ // Delete the reverse-dependency set
+ delCommands = append(delCommands, builder.Del().Key(currentDepKey).Build())
+
+ // Process each dependent cache entry
+ for _, cKey := range dependents {
+ // --- Clean up this dependent's forward dependencies ---
+ // Remove this dependent from the reverse-dependency sets of items it depends on
+ cKeyDepsKey := depsForKey(cKey)
+ cKeyDepsResult := m.client.Do(ctx, builder.Smembers().Key(cKeyDepsKey).Build())
+ if cKeyDeps, err := cKeyDepsResult.AsStrSlice(); err == nil {
+ for _, dep := range cKeyDeps {
+ // Remove from the reverse dependency set
+ // Skip if it's the set we're currently processing (already being deleted)
+ if dep != currentDepKey {
+ delCommands = append(delCommands, builder.Srem().Key(dep).Member(cKey).Build())
+ }
+ }
+ }
+
+ // Delete the dependent's cache data
+ delCommands = append(delCommands, builder.Del().Key(cKey).Build())
+
+ // Delete the dependent's forward-dependency list
+ delCommands = append(delCommands, builder.Del().Key(cKeyDepsKey).Build())
+
+ // Queue the dependent's reverse-dependency key for cascade invalidation
+ // This allows us to find and invalidate anything that depends on this dependent
+ nextDepKey := depKeyFromCacheKey(cKey)
+ if !visited[nextDepKey] {
+ queue = append(queue, nextDepKey)
+ }
+ }
+ }
+
+ // Execute all deletions in a batch
+ if len(delCommands) == 0 {
+ return nil
+ }
+
+ // Valkey can handle large DoMulti, but we could batch if needed
+ results := m.client.DoMulti(ctx, delCommands...)
+
+ // Check for errors in deletion results
+ for i, result := range results {
+ if err := result.Error(); err != nil {
+ m.logger.Warn("cache deletion error", "command_index", i, "error", err.Error())
+ // Deletion errors are typically not critical (key might not exist)
+ // but we should still report them
+ if !valkey.IsValkeyNil(err) {
+ return fmt.Errorf("%w: deletion command %d failed: %w", ErrCommandExecution, i, err)
+ }
+ }
+ }
+
+ return nil
+}
+
+// Set is a generic function that encodes typed data and stores it in the cache.
+// It handles the encoding based on the specified encoder type and wraps the data
+// in a CachedItem before storing it.
+//
+// Parameters:
+// - ctx: Context for the operation
+// - manager: The CacheManager instance to use
+// - encoderType: The encoder type (JSON or Gob)
+// - identifier: The cache identifier for the item
+// - dependencies: List of dependencies for this cache entry
+// - data: The typed data to cache
+// - ttl: Time-to-live for the cache entry
+//
+// Returns an error if encoding or caching fails.
+func Set[T any](
+ ctx context.Context,
+ manager *CacheManager,
+ encoderType CacheEncoderType,
+ identifier CacheIdentifier,
+ dependencies []CacheIdentifier,
+ data T,
+ ttl time.Duration,
+) error {
+ var serializedData []byte
+ var err error
+
+ // Encode based on type
+ switch encoderType {
+ case CacheEncoderTypeGob:
+ serializedData, err = EncodeGob(data)
+ case CacheEncoderTypeJSON:
+ fallthrough
+ default:
+ serializedData, err = EncodeJSON(data)
+ }
+
+ if err != nil {
+ return fmt.Errorf("failed to encode value: %w", err)
+ }
+
+ // Wrap in CachedItem with soft TTL
+ item := CachedItem{
+ Data: serializedData,
+ RefreshAt: time.Now().Add(ttl / 2).Unix(), // Default soft TTL is half of hard TTL
+ }
+
+ wrapperData, err := json.Marshal(item)
+ if err != nil {
+ return fmt.Errorf("failed to marshal wrapper: %w", err)
+ }
+
+ return manager.Set(ctx, identifier, wrapperData, dependencies, ttl)
+}
+
+// Get is a generic function that retrieves and decodes typed data from the cache.
+// It handles the decoding based on the specified encoder type and unwraps the data
+// from the CachedItem wrapper.
+//
+// Parameters:
+// - ctx: Context for the operation
+// - manager: The CacheManager instance to use
+// - encoderType: The encoder type (JSON or Gob) used when storing the data
+// - identifier: The cache identifier for the item
+// - ttl: Client-side cache TTL (ignored if client cache is disabled)
+//
+// Returns the decoded data of type T and an error if retrieval or decoding fails.
+// Returns ErrCacheMiss if the item is not found in the cache.
+func Get[T any](
+ ctx context.Context,
+ manager *CacheManager,
+ encoderType CacheEncoderType,
+ identifier CacheIdentifier,
+ ttl time.Duration,
+) (T, error) {
+ var result T
+
+ // Get the raw data from cache
+ wrapperData, err := manager.Get(ctx, identifier, ttl)
+ if err != nil {
+ return result, err
+ }
+
+ // Unmarshal the CachedItem wrapper
+ var item CachedItem
+ if err := json.Unmarshal(wrapperData, &item); err != nil {
+ return result, fmt.Errorf("failed to unmarshal wrapper: %w", err)
+ }
+
+ // Decode the inner data based on encoder type
+ switch encoderType {
+ case CacheEncoderTypeGob:
+ result, err = DecodeGob[T](item.Data)
+ if err != nil {
+ return result, fmt.Errorf("failed to decode gob data: %w", err)
+ }
+ case CacheEncoderTypeJSON:
+ fallthrough
+ default:
+ if err := json.Unmarshal(item.Data, &result); err != nil {
+ return result, fmt.Errorf("failed to unmarshal json data: %w", err)
+ }
+ }
+
+ return result, nil
+}
diff --git a/manager_test.go b/manager_test.go
new file mode 100644
index 0000000..5fdee55
--- /dev/null
+++ b/manager_test.go
@@ -0,0 +1,276 @@
+package c3e
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+)
+
+func TestNewCacheManager(t *testing.T) {
+ t.Run("success", func(t *testing.T) {
+ mock := newMockCacheRepository()
+ manager, err := NewCacheManager(mock, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ if manager == nil {
+ t.Fatal("expected non-nil manager")
+ }
+
+ if manager.client != mock {
+ t.Error("expected client to be set")
+ }
+ })
+
+ t.Run("nil_client", func(t *testing.T) {
+ manager, err := NewCacheManager(nil, false)
+ if err == nil {
+ t.Fatal("expected error for nil client")
+ }
+
+ if manager != nil {
+ t.Error("expected nil manager")
+ }
+
+ if err.Error() != "client cannot be nil" {
+ t.Errorf("unexpected error message: %s", err.Error())
+ }
+ })
+
+ t.Run("with_client_cache_enabled", func(t *testing.T) {
+ mock := newMockCacheRepository()
+ manager, err := NewCacheManager(mock, true)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ if !manager.clientCacheEnabled {
+ t.Error("expected clientCacheEnabled to be true")
+ }
+ })
+
+ t.Run("with_client_cache_disabled", func(t *testing.T) {
+ mock := newMockCacheRepository()
+ manager, err := NewCacheManager(mock, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ if manager.clientCacheEnabled {
+ t.Error("expected clientCacheEnabled to be false")
+ }
+ })
+}
+
+func TestCacheManager_Set_WithDependencies(t *testing.T) {
+ // Test that CacheIdentifier can be used with dependencies parameter
+ identifier := CacheIdentifier{Type: "user", ID: "123"}
+ deps := []CacheIdentifier{{Type: "org", ID: "456"}}
+
+ // Verify that the types are correct
+ if identifier.Type != "user" || identifier.ID != "123" {
+ t.Error("identifier not set correctly")
+ }
+
+ if len(deps) != 1 || deps[0].Type != "org" || deps[0].ID != "456" {
+ t.Error("dependencies not set correctly")
+ }
+}
+
+func TestCacheManager_Set_MultipleDependencies(t *testing.T) {
+ // Test that multiple CacheIdentifiers can be used with dependencies parameter
+ deps := []CacheIdentifier{
+ {Type: "org", ID: "123"},
+ {Type: "team", ID: "456"},
+ {Type: "project", ID: "789"},
+ }
+ identifier := CacheIdentifier{Type: "user", ID: "123"}
+
+ // Verify that all dependencies are set correctly
+ if len(deps) != 3 {
+ t.Errorf("expected 3 dependencies, got %d", len(deps))
+ }
+
+ if deps[0].Type != "org" || deps[0].ID != "123" {
+ t.Error("first dependency not set correctly")
+ }
+
+ if deps[1].Type != "team" || deps[1].ID != "456" {
+ t.Error("second dependency not set correctly")
+ }
+
+ if deps[2].Type != "project" || deps[2].ID != "789" {
+ t.Error("third dependency not set correctly")
+ }
+
+ if identifier.Type != "user" || identifier.ID != "123" {
+ t.Error("identifier not set correctly")
+ }
+}
+
+func TestCacheKeyGeneration(t *testing.T) {
+ tests := []struct {
+ name string
+ entityType string
+ entityID string
+ expected string
+ }{
+ {"simple", "user", "123", "cache:user:123"},
+ {"with_dash", "user-profile", "456", "cache:user-profile:456"},
+ {"with_underscore", "api_key", "abc", "cache:api_key:abc"},
+ {"numeric_id", "project", "999", "cache:project:999"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ identifier := CacheIdentifier{Type: tt.entityType, ID: tt.entityID}
+ result := cacheKey(identifier)
+ if result != tt.expected {
+ t.Errorf("expected %s, got %s", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestDepKeyGeneration(t *testing.T) {
+ tests := []struct {
+ name string
+ entityType string
+ entityID string
+ expected string
+ }{
+ {"simple", "user", "123", "dep:user:123"},
+ {"with_dash", "user-profile", "456", "dep:user-profile:456"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ identifier := CacheIdentifier{Type: tt.entityType, ID: tt.entityID}
+ result := depKey(identifier)
+ if result != tt.expected {
+ t.Errorf("expected %s, got %s", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestDepsForKeyGeneration(t *testing.T) {
+ tests := []struct {
+ name string
+ cacheKey string
+ expected string
+ }{
+ {"simple", "cache:user:123", "deps-for:cache:user:123"},
+ {"nested", "cache:project:456", "deps-for:cache:project:456"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := depsForKey(tt.cacheKey)
+ if result != tt.expected {
+ t.Errorf("expected %s, got %s", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestEntityKeyFromDepKey(t *testing.T) {
+ tests := []struct {
+ name string
+ depKey string
+ expected string
+ }{
+ {"simple", "dep:user:123", "user:123"},
+ {"with_colon", "dep:api:key:abc", "api:key:abc"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := entityKeyFromDepKey(tt.depKey)
+ if result != tt.expected {
+ t.Errorf("expected %s, got %s", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestDepKeyFromCacheKey(t *testing.T) {
+ tests := []struct {
+ name string
+ cacheKey string
+ expected string
+ }{
+ {"simple", "cache:user:123", "dep:user:123"},
+ {"nested", "cache:project:456", "dep:project:456"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := depKeyFromCacheKey(tt.cacheKey)
+ if result != tt.expected {
+ t.Errorf("expected %s, got %s", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestCacheManager_Set_NilDependencies(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ // If the panic is due to the mock returning an unusable builder (because no Valkey is running),
+ // we skip the test.
+ t.Logf("Recovered from panic: %v. This is expected if Valkey is not running.", r)
+ t.Skip("Skipping test because Valkey is not available to create a valid Builder")
+ }
+ }()
+
+ mock := newMockCacheRepository()
+
+ // Mock Do to return empty old dependencies
+ mock.doFunc = func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ return valkey.ValkeyResult{}
+ }
+
+ // Mock DoMulti to return success
+ mock.doMultiFunc = func(ctx context.Context, multi ...valkey.Completed) []valkey.ValkeyResult {
+ return make([]valkey.ValkeyResult, len(multi))
+ }
+
+ manager, err := NewCacheManager(mock, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ ctx := context.Background()
+ identifier := CacheIdentifier{Type: "user", ID: "123"}
+ data := []byte("some data")
+
+ // Test with nil dependencies
+ err = manager.Set(ctx, identifier, data, nil, time.Minute)
+ if err != nil {
+ t.Logf("Set returned error: %v", err)
+ }
+}
+
+// Note: Unit tests for client-side caching (Get/Set with clientCacheEnabled=true)
+// are not included here because they require a running Valkey instance or a complex
+// mock of valkey.Builder which is difficult to achieve without a real client.
+// The integration tests cover these scenarios.
+
+// Benchmark tests
+func BenchmarkCacheKeyGeneration(b *testing.B) {
+ identifier := CacheIdentifier{Type: "user", ID: "123"}
+ for b.Loop() {
+ cacheKey(identifier)
+ }
+}
+
+func BenchmarkDepKeyGeneration(b *testing.B) {
+ identifier := CacheIdentifier{Type: "user", ID: "123"}
+ for b.Loop() {
+ depKey(identifier)
+ }
+}
diff --git a/mock_test.go b/mock_test.go
new file mode 100644
index 0000000..3100535
--- /dev/null
+++ b/mock_test.go
@@ -0,0 +1,70 @@
+package c3e
+
+import (
+ "context"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+)
+
+// mockCacheRepository is a mock implementation of CacheRepository for testing
+type mockCacheRepository struct {
+ doFunc func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult
+ doMultiFunc func(ctx context.Context, multi ...valkey.Completed) []valkey.ValkeyResult
+ doCacheFunc func(ctx context.Context, cmd valkey.Cacheable, ttl time.Duration) valkey.ValkeyResult
+ doMultiCacheFunc func(ctx context.Context, multi ...valkey.CacheableTTL) []valkey.ValkeyResult
+ builderFunc func() valkey.Builder
+}
+
+func newMockCacheRepository() *mockCacheRepository {
+ return &mockCacheRepository{}
+}
+
+func (m *mockCacheRepository) Do(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ if m.doFunc != nil {
+ return m.doFunc(ctx, cmd)
+ }
+ return valkey.ValkeyResult{}
+}
+
+func (m *mockCacheRepository) DoMulti(ctx context.Context, multi ...valkey.Completed) []valkey.ValkeyResult {
+ if m.doMultiFunc != nil {
+ return m.doMultiFunc(ctx, multi...)
+ }
+ results := make([]valkey.ValkeyResult, len(multi))
+ return results
+}
+
+func (m *mockCacheRepository) DoCache(ctx context.Context, cmd valkey.Cacheable, ttl time.Duration) valkey.ValkeyResult {
+ if m.doCacheFunc != nil {
+ return m.doCacheFunc(ctx, cmd, ttl)
+ }
+ return valkey.ValkeyResult{}
+}
+
+func (m *mockCacheRepository) DoMultiCache(ctx context.Context, multi ...valkey.CacheableTTL) []valkey.ValkeyResult {
+ if m.doMultiCacheFunc != nil {
+ return m.doMultiCacheFunc(ctx, multi...)
+ }
+ results := make([]valkey.ValkeyResult, len(multi))
+ return results
+}
+
+func (m *mockCacheRepository) B() valkey.Builder {
+ if m.builderFunc != nil {
+ return m.builderFunc()
+ }
+ // Return a real builder for testing
+ // Use DisableCache to potentially avoid immediate connection or background routines
+ client, err := valkey.NewClient(valkey.ClientOption{
+ InitAddress: []string{"localhost:6379"},
+ DisableCache: true,
+ DisableRetry: true, // Prevent queueing when cache is down
+ })
+
+ if err != nil || client == nil {
+ // Fallback if client creation fails
+ return valkey.Builder{}
+ }
+ return client.B()
+}
diff --git a/safe_manager.go b/safe_manager.go
new file mode 100644
index 0000000..7e259c5
--- /dev/null
+++ b/safe_manager.go
@@ -0,0 +1,324 @@
+package c3e
+
+import (
+ "bytes"
+ "context"
+ "encoding/gob"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "golang.org/x/sync/singleflight"
+)
+
+type SafeCacheManagerConsumer interface {
+ Get(ctx context.Context, identifier CacheIdentifier, dest any, fetcher FetcherFunc) error
+ Invalidate(ctx context.Context, identifier CacheIdentifier) error
+}
+
+const (
+ // MaxSafeCacheManagerQueryTimeout is the maximum allowed timeout for queries in SafeCacheManager.
+ MaxSafeCacheManagerQueryTimeout = 500 * time.Millisecond
+
+ // MinSafeCacheManagerQueryTimeout is the minimum allowed timeout for queries in SafeCacheManager.
+ MinSafeCacheManagerQueryTimeout = 5 * time.Millisecond
+
+ // DefaultSafeCacheManagerQueryTimeout is the default timeout for queries in SafeCacheManager.
+ // Set to 70ms for faster fallback when cache is unavailable while still allowing most cache hits
+ DefaultSafeCacheManagerQueryTimeout = 70 * time.Millisecond
+
+ // backgroundRefreshTimeout bounds a detached stale-while-revalidate refresh.
+ // The refresh runs on a context.WithoutCancel copy of the request context so
+ // it survives the request returning; this timeout keeps it from running
+ // unbounded. (Phase 2 will expose this via a functional option.)
+ backgroundRefreshTimeout = 30 * time.Second
+)
+
+// FetcherFunc is the function signature for fetching data from the primary source.
+// It returns:
+// - data: The fresh data from your primary source (DB, API, etc.)
+// - dependencies: A list of CacheIdentifiers that this data depends on
+// - err: Any error encountered during fetching
+type FetcherFunc func(ctx context.Context) (data any, dependencies []CacheIdentifier, err error)
+
+// SafeCacheManagerConfig holds configuration for the SafeCacheManager.
+type SafeCacheManagerConfig struct {
+ Hooks Hooks // Optional observability callbacks; the zero value is a no-op
+ Logger *slog.Logger // Logger for the cache manager; defaults to slog.Default() when nil
+ EncoderType CacheEncoderType // The encoder type to use (JSON or Gob), defaults to JSON
+ HardTTL time.Duration // The hard expiration time
+ SoftTTL time.Duration // The time until the data is considered "stale"
+ JitterPercent float64 // The max % (0.0 to 1.0) of jitter
+ QueryTimeout time.Duration // The maximum duration for queries for this cache manager, defaults to 80 ms
+}
+
+// SafeCacheManager wraps the core CacheManager with thundering herd
+// protection and stale-while-revalidate logic.
+type SafeCacheManager struct {
+ hooks Hooks
+ cache *CacheManager
+ sfg *singleflight.Group
+ logger *slog.Logger
+ cfg SafeCacheManagerConfig
+}
+
+// NewSafeCacheManager creates the high-level, application-facing manager.
+func NewSafeCacheManager(cacheManager *CacheManager, config SafeCacheManagerConfig) (*SafeCacheManager, error) {
+ if cacheManager == nil {
+ return nil, fmt.Errorf("cacheManager cannot be nil")
+ }
+
+ // Validate only the library's structural invariants. Business policy
+ // bounds (e.g. "hard TTL must be 1โ72h") belong to the calling application,
+ // not to a general-purpose cache library.
+ if config.JitterPercent < 0 || config.JitterPercent >= 1 {
+ return nil, fmt.Errorf("JitterPercent must be in [0.0, 1.0)")
+ }
+
+ if config.HardTTL <= 0 {
+ return nil, fmt.Errorf("HardTTL must be greater than 0")
+ }
+
+ if config.SoftTTL < 0 || config.SoftTTL > config.HardTTL {
+ return nil, fmt.Errorf("SoftTTL must be between 0 and HardTTL")
+ }
+
+ if config.QueryTimeout == 0 {
+ config.QueryTimeout = DefaultSafeCacheManagerQueryTimeout
+ }
+
+ if config.QueryTimeout < MinSafeCacheManagerQueryTimeout || config.QueryTimeout > MaxSafeCacheManagerQueryTimeout {
+ return nil, fmt.Errorf("QueryTimeout must be between %s and %s", MinSafeCacheManagerQueryTimeout, MaxSafeCacheManagerQueryTimeout)
+ }
+
+ logger := config.Logger
+ if logger == nil {
+ logger = slog.Default()
+ }
+ // Propagate the resolved logger to the wrapped low-level manager so the
+ // whole stack logs through the same (injectable) logger.
+ cacheManager.logger = logger
+
+ return &SafeCacheManager{
+ cache: cacheManager,
+ sfg: &singleflight.Group{},
+ logger: logger,
+ cfg: config,
+ hooks: config.Hooks,
+ }, nil
+}
+
+// log returns the configured logger, falling back to slog.Default when nil.
+// Managers built directly as a struct literal (e.g. the per-request
+// GetWithEncoder path) may not have a logger set; this keeps every logging
+// call site panic-free regardless of how the manager was constructed.
+func (m *SafeCacheManager) log() *slog.Logger {
+ if m.logger != nil {
+ return m.logger
+ }
+ return slog.Default()
+}
+
+// Get retrieves an item, using all best-practice policies.
+// - dest: A pointer to the variable to unmarshal data into (e.g., &Project{})
+// - fetcher: The function to call on a cache miss.
+func (m *SafeCacheManager) Get(ctx context.Context, identifier CacheIdentifier, dest any, fetcher FetcherFunc) error {
+ cKey := cacheKey(identifier)
+
+ // Report the cache outcome + elapsed time to the OnGet hook on return.
+ start := time.Now()
+ result := ResultError
+ defer func() { m.hooks.onGet(ctx, identifier, result, time.Since(start)) }()
+
+ var wrapperData []byte
+ var err error
+
+ // 1. Create a derived context with the specific query timeout
+ cacheCtx, cancel := context.WithTimeout(ctx, m.cfg.QueryTimeout)
+ defer cancel()
+
+ // 2. Try the raw cache get - use the cacheCtx
+ // Use the CacheManager's Get method which handles client-side caching if enabled
+ wrapperData, err = m.cache.Get(cacheCtx, identifier, m.cfg.HardTTL)
+
+ // 3. Handle Cache HIT
+ if err == nil {
+ var item CachedItem
+ if err := json.Unmarshal(wrapperData, &item); err != nil {
+ m.log().Warn("cache: failed to unmarshal cached wrapper", "key", cKey, "error", err)
+
+ result = ResultMiss // corrupt entry โ refetch from source
+ return m.blockingFetch(ctx, identifier, dest, fetcher)
+ }
+
+ // 2a. Check if STALE (soft TTL expired)
+ if time.Now().Unix() > item.RefreshAt {
+ // --- CASE B: STALE (Serve stale, refresh in background) ---
+ // Launch a NON-BLOCKING refresh.
+ //
+ // The refresh MUST NOT use the request context: the caller returns
+ // as soon as we serve the stale value below, which cancels that
+ // context and would abort the in-flight fetch/cache-write, so the
+ // value would stay stale until the hard TTL forces a blocking
+ // fetch. context.WithoutCancel keeps request-scoped values (e.g.
+ // the trace span) while dropping cancellation; a dedicated timeout
+ // bounds the detached work.
+ bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backgroundRefreshTimeout)
+ go func() {
+ defer cancel()
+ _, _, _ = m.sfg.Do(cKey, func() (any, error) {
+ // We only care about errors for logging
+ _, err := m.fetchAndCache(bgCtx, identifier, fetcher)
+ if err != nil {
+ m.log().Warn("cache: background refresh failed", "key", cKey, "error", err)
+ }
+ m.hooks.onRefresh(bgCtx, identifier, err)
+
+ return nil, nil // Result doesn't matter
+ })
+ }()
+ result = ResultStale
+ } else {
+ result = ResultHit
+ }
+
+ // --- CASE A (Fresh) or B (Stale) ---
+ // In both cases, we serve the data we have *right now*.
+ return m.unmarshalData(item.Data, dest)
+ }
+
+ // 4. Handle TIMEOUT
+ // If cache query timeout is reached but main context is still valid, fallback to database
+ if ctx.Err() == nil && cacheCtx.Err() != nil {
+ m.log().Warn("cache: query timeout reached, falling back to database",
+ "key", cKey,
+ "timeout", m.cfg.QueryTimeout)
+ result = ResultTimeout
+ return m.blockingFetch(ctx, identifier, dest, fetcher)
+ }
+
+ // 5. Handle Cache MISS
+ if err == ErrCacheMiss {
+ // --- CASE C: HARD MISS (Fetch, block, and return) ---
+ result = ResultMiss
+ return m.blockingFetch(ctx, identifier, dest, fetcher)
+ }
+
+ // 6. Handle ALL other cache errors (connectivity, network, parsing, etc.)
+ // Immediately fallback to database - don't wait or retry
+ // This ensures fast recovery when cache is unavailable
+ m.log().Warn("cache: error accessing cache, falling back to database",
+ "key", cKey,
+ "error", err)
+ return m.blockingFetch(ctx, identifier, dest, fetcher)
+}
+
+// Invalidate invalidates the cache for a given entity.
+func (m *SafeCacheManager) Invalidate(ctx context.Context, identifier CacheIdentifier) error {
+ start := time.Now()
+ err := m.cache.Invalidate(ctx, identifier)
+ m.hooks.onInvalidate(ctx, identifier, time.Since(start), err)
+ return err
+}
+
+// blockingFetch does a synchronous fetch and cache.
+func (m *SafeCacheManager) blockingFetch(ctx context.Context, identifier CacheIdentifier, dest any, fetcher FetcherFunc) error {
+ cKey := cacheKey(identifier)
+
+ // sfg.Do ensures this block runs only once for a given cKey
+ res, err, _ := m.sfg.Do(cKey, func() (any, error) {
+ return m.fetchAndCache(ctx, identifier, fetcher)
+ })
+
+ if err != nil {
+ // do not wrap the error here to preserve its type
+ // return fmt.Errorf("cache: failed to fetch data: %w", err)
+ return err
+ }
+
+ // `res` is the `wrapperData` ([]byte) from fetchAndCache
+ var item CachedItem
+ if err := json.Unmarshal(res.([]byte), &item); err != nil {
+ return fmt.Errorf("cache: failed to unmarshal fetched wrapper: %w", err)
+ }
+
+ // Unmarshal the inner data into the user's destination
+ return m.unmarshalData(item.Data, dest)
+}
+
+// unmarshalData decodes the data using the configured encoder type.
+func (m *SafeCacheManager) unmarshalData(data []byte, dest any) error {
+ encoderType := m.cfg.EncoderType
+ if encoderType == "" {
+ encoderType = CacheEncoderTypeJSON
+ }
+
+ switch encoderType {
+ case CacheEncoderTypeGob:
+ buf := bytes.NewReader(data)
+ return gob.NewDecoder(buf).Decode(dest)
+ case CacheEncoderTypeJSON:
+ fallthrough
+ default:
+ return json.Unmarshal(data, dest)
+ }
+}
+
+// fetchAndCache is the single-flight function that does the work.
+// It returns the serialized wrapper (`[]byte`) to the singleflight group.
+func (m *SafeCacheManager) fetchAndCache(ctx context.Context, identifier CacheIdentifier, fetcher FetcherFunc) (any, error) {
+ // 1. Get data and dependencies from the primary source
+ data, deps, err := fetcher(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // 2. Serialize the inner data based on encoder type
+ var serializedData []byte
+ encoderType := m.cfg.EncoderType
+ if encoderType == "" {
+ encoderType = CacheEncoderTypeJSON // Default to JSON
+ }
+
+ switch encoderType {
+ case CacheEncoderTypeGob:
+ serializedData, err = EncodeGob(data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to encode data with gob: %w", err)
+ }
+ case CacheEncoderTypeJSON:
+ fallthrough
+ default:
+ serializedData, err = json.Marshal(data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal data: %w", err)
+ }
+ }
+
+ // 3. Create the cache wrapper
+ item := CachedItem{
+ Data: serializedData,
+ RefreshAt: time.Now().Add(m.cfg.SoftTTL).Unix(),
+ }
+
+ // 4. Serialize the wrapper
+ wrapperData, err := json.Marshal(item)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal wrapper: %w", err)
+ }
+
+ // 5. Apply jitter to hard TTL
+ finalTTL := jitterTTL(m.cfg.HardTTL, m.cfg.JitterPercent)
+
+ // 6. Set in cache - use client-side caching if enabled
+ // The Set method handles client-side caching internally if enabled in CacheManager
+ err = m.cache.Set(ctx, identifier, wrapperData, deps, finalTTL)
+ if err != nil {
+ // Log but return the data anyway, as we successfully fetched it
+ m.log().Warn("cache: failed to set cache", "key", cacheKey(identifier), "error", err)
+ }
+
+ // 7. Return the serialized wrapper to singleflight (for blocking waiters)
+ return wrapperData, nil
+}
diff --git a/safe_manager_test.go b/safe_manager_test.go
new file mode 100644
index 0000000..fbddc00
--- /dev/null
+++ b/safe_manager_test.go
@@ -0,0 +1,576 @@
+package c3e
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+)
+
+type testData struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+}
+
+func TestNewSafeCacheManager(t *testing.T) {
+ t.Run("success", func(t *testing.T) {
+ mockRepo := newMockCacheRepository()
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ if safeMgr == nil {
+ t.Fatal("expected non-nil SafeCacheManager")
+ }
+
+ if safeMgr.cache != cacheManager {
+ t.Error("expected cache manager to be set")
+ }
+
+ if safeMgr.cfg.HardTTL != config.HardTTL {
+ t.Errorf("expected HardTTL %v, got %v", config.HardTTL, safeMgr.cfg.HardTTL)
+ }
+
+ if safeMgr.cfg.SoftTTL != config.SoftTTL {
+ t.Errorf("expected SoftTTL %v, got %v", config.SoftTTL, safeMgr.cfg.SoftTTL)
+ }
+
+ if safeMgr.sfg == nil {
+ t.Error("expected singleflight group to be initialized")
+ }
+ })
+
+ t.Run("nil_cache_manager", func(t *testing.T) {
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ }
+
+ safeMgr, err := NewSafeCacheManager(nil, config)
+ if err == nil {
+ t.Fatal("expected error for nil cache manager")
+ }
+
+ if safeMgr != nil {
+ t.Error("expected nil safe cache manager")
+ }
+
+ if err.Error() != "cacheManager cannot be nil" {
+ t.Errorf("unexpected error message: %s", err.Error())
+ }
+ })
+}
+
+func TestSafeCacheManagerConstants(t *testing.T) {
+ t.Run("max_query_timeout", func(t *testing.T) {
+ if MaxSafeCacheManagerQueryTimeout != 500*time.Millisecond {
+ t.Errorf("expected 500ms, got %v", MaxSafeCacheManagerQueryTimeout)
+ }
+ })
+
+ t.Run("min_query_timeout", func(t *testing.T) {
+ if MinSafeCacheManagerQueryTimeout != 5*time.Millisecond {
+ t.Errorf("expected 5ms, got %v", MinSafeCacheManagerQueryTimeout)
+ }
+ })
+
+ t.Run("default_query_timeout", func(t *testing.T) {
+ if DefaultSafeCacheManagerQueryTimeout != 70*time.Millisecond {
+ t.Errorf("expected 70ms, got %v", DefaultSafeCacheManagerQueryTimeout)
+ }
+ })
+
+ t.Run("min_less_than_default", func(t *testing.T) {
+ if MinSafeCacheManagerQueryTimeout >= DefaultSafeCacheManagerQueryTimeout {
+ t.Error("min should be less than default")
+ }
+ })
+
+ t.Run("default_less_than_max", func(t *testing.T) {
+ if DefaultSafeCacheManagerQueryTimeout >= MaxSafeCacheManagerQueryTimeout {
+ t.Error("default should be less than max")
+ }
+ })
+}
+
+func TestSafeCacheManager_Config_Validation(t *testing.T) {
+ tests := []struct {
+ name string
+ config SafeCacheManagerConfig
+ expectError bool
+ errorMsg string
+ }{
+ {
+ name: "valid_config",
+ config: SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1, // 10% jitter
+ },
+ expectError: false,
+ },
+ {
+ name: "no_jitter",
+ config: SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0, // no jitter
+ },
+ expectError: false,
+ },
+ {
+ name: "valid_config_with_query_timeout",
+ config: SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 70 * time.Millisecond,
+ },
+ expectError: false,
+ },
+ {
+ name: "valid_config_with_default_query_timeout",
+ config: SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 0, // Should use default
+ },
+ expectError: false,
+ },
+ {
+ name: "invalid_query_timeout_too_low",
+ config: SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 1 * time.Millisecond, // Below minimum
+ },
+ expectError: true,
+ errorMsg: "QueryTimeout must be between",
+ },
+ {
+ name: "invalid_query_timeout_too_high",
+ config: SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 600 * time.Millisecond, // Above maximum
+ },
+ expectError: true,
+ errorMsg: "QueryTimeout must be between",
+ },
+ {
+ name: "invalid_jitter_too_high",
+ config: SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 1.5, // Above 1.0
+ },
+ expectError: true,
+ errorMsg: "JitterPercent must be in",
+ },
+ {
+ name: "invalid_soft_ttl_exceeds_hard_ttl",
+ config: SafeCacheManagerConfig{
+ HardTTL: 1 * time.Hour,
+ SoftTTL: 2 * time.Hour, // Greater than HardTTL
+ JitterPercent: 0.1,
+ },
+ expectError: true,
+ errorMsg: "SoftTTL must be between 0 and HardTTL",
+ },
+ // The library validates only structural invariants; short TTLs are
+ // valid (business bounds like "โฅ 1h" live in the calling app).
+ {
+ name: "structural_short_hard_ttl_ok",
+ config: SafeCacheManagerConfig{
+ HardTTL: 1 * time.Second,
+ SoftTTL: 500 * time.Millisecond,
+ JitterPercent: 0.1,
+ },
+ expectError: false,
+ },
+ {
+ name: "structural_zero_soft_ttl_ok",
+ config: SafeCacheManagerConfig{
+ HardTTL: 1 * time.Hour,
+ SoftTTL: 0,
+ JitterPercent: 0.1,
+ },
+ expectError: false,
+ },
+ {
+ name: "invalid_hard_ttl_zero",
+ config: SafeCacheManagerConfig{
+ HardTTL: 0,
+ SoftTTL: 0,
+ JitterPercent: 0.1,
+ },
+ expectError: true,
+ errorMsg: "HardTTL must be greater than 0",
+ },
+ {
+ name: "invalid_jitter_at_one",
+ config: SafeCacheManagerConfig{
+ HardTTL: 1 * time.Hour,
+ SoftTTL: 30 * time.Minute,
+ JitterPercent: 1.0, // must be strictly < 1
+ },
+ expectError: true,
+ errorMsg: "JitterPercent must be in",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockRepo := newMockCacheRepository()
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+ safeMgr, err := NewSafeCacheManager(cacheManager, tt.config)
+
+ if tt.expectError {
+ if err == nil {
+ t.Errorf("expected error but got none")
+ } else if tt.errorMsg != "" && !containsString(err.Error(), tt.errorMsg) {
+ t.Errorf("expected error message to contain %q, got %q", tt.errorMsg, err.Error())
+ }
+ } else {
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ if safeMgr == nil {
+ t.Error("expected manager to be created")
+ }
+
+ // Verify default QueryTimeout is set when 0 is provided
+ if tt.config.QueryTimeout == 0 && safeMgr.cfg.QueryTimeout != DefaultSafeCacheManagerQueryTimeout {
+ t.Errorf("expected default QueryTimeout %v, got %v", DefaultSafeCacheManagerQueryTimeout, safeMgr.cfg.QueryTimeout)
+ }
+
+ // Verify explicit QueryTimeout is preserved
+ if tt.config.QueryTimeout > 0 && safeMgr.cfg.QueryTimeout != tt.config.QueryTimeout {
+ t.Errorf("expected QueryTimeout %v, got %v", tt.config.QueryTimeout, safeMgr.cfg.QueryTimeout)
+ }
+ }
+ })
+ }
+}
+
+// containsString checks if s contains substr
+func containsString(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
+ (len(s) > 0 && len(substr) > 0 && stringContains(s, substr)))
+}
+
+func stringContains(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
+
+func TestSafeCacheManager_Invalidate(t *testing.T) {
+ mockRepo := newMockCacheRepository()
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1, // 10% jitter
+ }
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ // Test that SafeCacheManager delegates to CacheManager correctly
+ // The actual invalidation logic is tested in CacheManager tests
+ // This test just verifies the delegation works
+ _ = safeMgr
+
+ // Note: Full invalidation testing requires integration tests with actual Valkey
+ t.Log("SafeCacheManager created successfully and delegates to CacheManager")
+}
+
+func TestFetcherFunc_Success(t *testing.T) {
+ fetcher := func(ctx context.Context) (any, []string, error) {
+ return testData{ID: 42, Name: "Answer"}, []string{"dep:1", "dep:2"}, nil
+ }
+
+ data, deps, err := fetcher(context.Background())
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ td, ok := data.(testData)
+ if !ok {
+ t.Fatal("expected testData type")
+ }
+
+ if td.ID != 42 {
+ t.Errorf("expected ID 42, got %d", td.ID)
+ }
+
+ if len(deps) != 2 {
+ t.Errorf("expected 2 dependencies, got %d", len(deps))
+ }
+}
+
+func TestFetcherFunc_Error(t *testing.T) {
+ expectedErr := errors.New("fetch failed")
+ fetcher := func(ctx context.Context) (any, []string, error) {
+ return nil, nil, expectedErr
+ }
+
+ _, _, err := fetcher(context.Background())
+ if err == nil {
+ t.Fatal("expected error")
+ }
+
+ if !errors.Is(err, expectedErr) {
+ t.Errorf("expected specific error, got %v", err)
+ }
+}
+
+func TestSafeCacheManager_Get_CacheTimeout_FallbackToDatabase(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ // If the panic is due to the builder validation (because no Valkey is running),
+ // we skip the test.
+ t.Logf("Recovered from panic: %v. This is expected if Valkey is not running.", r)
+ t.Skip("Skipping test because Valkey is not available to create a valid Builder")
+ }
+ }()
+
+ mockRepo := newMockCacheRepository()
+
+ // Configure mock to simulate a slow cache response that will timeout
+ mockRepo.doFunc = func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ // Simulate a slow cache operation that exceeds the query timeout
+ select {
+ case <-time.After(100 * time.Millisecond): // Longer than our query timeout
+ return valkey.ValkeyResult{}
+ case <-ctx.Done():
+ // Return empty result; the manager will check ctx.Err()
+ return valkey.ValkeyResult{}
+ }
+ }
+
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 50 * time.Millisecond, // Short timeout to trigger fallback
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ // Create a fetcher that should be called when cache times out
+ fetcherCalled := false
+ fetcher := func(ctx context.Context) (any, []CacheIdentifier, error) {
+ fetcherCalled = true
+ return testData{ID: 123, Name: "Fallback Data"}, nil, nil
+ }
+
+ // Main context with longer timeout (should not expire)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ var result testData
+ identifier := CacheIdentifier{Type: "test", ID: "timeoutkey"}
+
+ err = safeMgr.Get(ctx, identifier, &result, fetcher)
+ // Should succeed by falling back to database
+ if err != nil {
+ t.Fatalf("expected Get to succeed with fallback, got error: %v", err)
+ }
+
+ // Verify fetcher was called (fallback to database)
+ if !fetcherCalled {
+ t.Error("expected fetcher to be called due to cache timeout")
+ }
+
+ // Verify we got the data from the fetcher
+ if result.ID != 123 {
+ t.Errorf("expected ID 123, got %d", result.ID)
+ }
+
+ if result.Name != "Fallback Data" {
+ t.Errorf("expected Name 'Fallback Data', got %q", result.Name)
+ }
+
+ // Verify main context is still valid
+ if ctx.Err() != nil {
+ t.Errorf("expected main context to still be valid, got error: %v", ctx.Err())
+ }
+}
+
+func TestSafeCacheManager_Get_MainContextExpired(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ // If the panic is due to the builder validation (because no Valkey is running),
+ // we skip the test.
+ t.Logf("Recovered from panic: %v. This is expected if Valkey is not running.", r)
+ t.Skip("Skipping test because Valkey is not available to create a valid Builder")
+ }
+ }()
+
+ mockRepo := newMockCacheRepository()
+
+ // Configure mock to simulate a slow response
+ mockRepo.doFunc = func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ select {
+ case <-time.After(200 * time.Millisecond):
+ return valkey.ValkeyResult{}
+ case <-ctx.Done():
+ return valkey.ValkeyResult{}
+ }
+ }
+
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 300 * time.Millisecond, // Longer than main context
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ fetcherCalled := false
+ fetcher := func(ctx context.Context) (any, []CacheIdentifier, error) {
+ fetcherCalled = true
+ // Check if context is expired
+ if ctx.Err() != nil {
+ return nil, nil, ctx.Err()
+ }
+ return testData{ID: 456, Name: "Test"}, nil, nil
+ }
+
+ // Main context with very short timeout (expires before cache timeout)
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+
+ var result testData
+ identifier := CacheIdentifier{Type: "test", ID: "mainctxexpired"}
+
+ err = safeMgr.Get(ctx, identifier, &result, fetcher)
+
+ // Should get a context error
+ if err == nil {
+ t.Fatal("expected error due to main context expiration")
+ }
+
+ // The fetcher might be called but should also fail due to context
+ if fetcherCalled {
+ // If fetcher was called, it should have returned a context error
+ if !errors.Is(err, context.DeadlineExceeded) && !errors.Is(err, context.Canceled) {
+ t.Logf("fetcher was called and returned error: %v", err)
+ }
+ }
+}
+
+func TestSafeCacheManager_Get_CacheHit_WithinTimeout(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ // If the panic is due to the builder validation (because no Valkey is running),
+ // we skip the test.
+ t.Logf("Recovered from panic: %v. This is expected if Valkey is not running.", r)
+ t.Skip("Skipping test because Valkey is not available to create a valid Builder")
+ }
+ }()
+
+ mockRepo := newMockCacheRepository()
+
+ // Configure mock to return cached data quickly (within timeout)
+ mockRepo.doFunc = func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ // This test is simplified because ValkeyResult cannot be easily mocked
+ // The real behavior is tested in integration tests
+ return valkey.ValkeyResult{}
+ }
+
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 50 * time.Millisecond,
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ // Fetcher should be called since mock returns empty result (cache miss)
+ fetcherCalled := false
+ fetcher := func(ctx context.Context) (any, []CacheIdentifier, error) {
+ fetcherCalled = true
+ return testData{ID: 999, Name: "Fetched Data"}, nil, nil
+ }
+
+ ctx := context.Background()
+ var result testData
+ identifier := CacheIdentifier{Type: "test", ID: "cachehit"}
+
+ err = safeMgr.Get(ctx, identifier, &result, fetcher)
+ // With empty ValkeyResult, this will be a cache miss and call fetcher
+ if err != nil {
+ t.Fatalf("expected Get to succeed, got error: %v", err)
+ }
+
+ if !fetcherCalled {
+ t.Error("expected fetcher to be called on cache miss")
+ }
+
+ if result.ID != 999 {
+ t.Errorf("expected ID 999, got %d", result.ID)
+ }
+
+ if result.Name != "Fetched Data" {
+ t.Errorf("expected Name 'Fetched Data', got %q", result.Name)
+ }
+}
diff --git a/typed_manager.go b/typed_manager.go
new file mode 100644
index 0000000..c3efd6f
--- /dev/null
+++ b/typed_manager.go
@@ -0,0 +1,170 @@
+package c3e
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "golang.org/x/sync/singleflight"
+)
+
+// TypedFetcherFunc is a type-safe fetcher function.
+type TypedFetcherFunc[T any] func(ctx context.Context) (data T, dependencies []CacheIdentifier, err error)
+
+// TypedSafeCacheManager is a generic wrapper around SafeCacheManager that provides
+// type-safe operations with a specific data type.
+type TypedSafeCacheManager[T any] struct {
+ manager *SafeCacheManager
+}
+
+// NewTypedSafeCacheManager creates a type-safe cache manager for a specific type.
+func NewTypedSafeCacheManager[T any](manager *SafeCacheManager) *TypedSafeCacheManager[T] {
+ return &TypedSafeCacheManager[T]{
+ manager: manager,
+ }
+}
+
+// GetSafe retrieves an item from the SafeCacheManager with type safety.
+// This is a standalone generic function alternative to TypedSafeCacheManager.
+func GetSafe[T any](ctx context.Context, manager *SafeCacheManager, identifier CacheIdentifier, fetcher TypedFetcherFunc[T]) (T, error) {
+ var result T
+ untypedFetcher := func(ctx context.Context) (any, []CacheIdentifier, error) {
+ return fetcher(ctx)
+ }
+
+ err := manager.Get(ctx, identifier, &result, untypedFetcher)
+
+ return result, err
+}
+
+// Get retrieves an item with type safety.
+func (m *TypedSafeCacheManager[T]) Get(ctx context.Context, identifier CacheIdentifier, fetcher TypedFetcherFunc[T]) (T, error) {
+ var result T
+
+ // Wrap the typed fetcher into the untyped one
+ untypedFetcher := func(ctx context.Context) (any, []CacheIdentifier, error) {
+ return fetcher(ctx)
+ }
+
+ err := m.manager.Get(ctx, identifier, &result, untypedFetcher)
+
+ return result, err
+}
+
+// Invalidate provides direct access to the underlying invalidation.
+func (m *TypedSafeCacheManager[T]) Invalidate(ctx context.Context, identifier CacheIdentifier) error {
+ return m.manager.Invalidate(ctx, identifier)
+}
+
+// GetOrFetch is a convenience method that combines Get with a direct database query function.
+func (m *TypedSafeCacheManager[T]) GetOrFetch(ctx context.Context, identifier CacheIdentifier, queryFunc func(ctx context.Context) (data T, dependencies []CacheIdentifier, err error)) (T, error) {
+ return GetOrFetch(ctx, m.manager, identifier, queryFunc)
+}
+
+// GetOrFetch is a convenience method that combines Get with a direct database query function.
+// It uses the encoder type configured in the SafeCacheManager.
+func GetOrFetch[T any](
+ ctx context.Context,
+ manager *SafeCacheManager,
+ identifier CacheIdentifier,
+ queryFunc func(ctx context.Context) (data T, dependencies []CacheIdentifier, err error),
+) (T, error) {
+ var result T
+
+ fetcher := func(ctx context.Context) (any, []CacheIdentifier, error) {
+ data, dependencies, err := queryFunc(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return data, dependencies, nil
+ }
+
+ err := manager.Get(ctx, identifier, &result, fetcher)
+
+ return result, err
+}
+
+// GetWithEncoder retrieves an item with explicit encoder type specification.
+// This allows per-request encoder selection instead of using the manager's default.
+func GetWithEncoder[T any](
+ ctx context.Context,
+ cacheManager *CacheManager,
+ config SafeCacheManagerConfig,
+ identifier CacheIdentifier,
+ fetcher TypedFetcherFunc[T],
+) (T, error) {
+ var result T
+
+ // This per-request path builds the manager directly instead of going
+ // through NewSafeCacheManager, so apply the same defaults the constructor
+ // would: a non-zero query timeout (a zero timeout yields an
+ // already-expired context and forces every read down the fallback path)
+ // and a non-nil logger.
+ if config.QueryTimeout == 0 {
+ config.QueryTimeout = DefaultSafeCacheManagerQueryTimeout
+ }
+ logger := config.Logger
+ if logger == nil {
+ logger = slog.Default()
+ }
+
+ // Create a temporary manager with the specific encoder
+ tempManager := &SafeCacheManager{
+ cache: cacheManager,
+ sfg: &singleflight.Group{},
+ logger: logger,
+ cfg: config,
+ hooks: config.Hooks,
+ }
+
+ untypedFetcher := func(ctx context.Context) (any, []CacheIdentifier, error) {
+ return fetcher(ctx)
+ }
+
+ err := tempManager.Get(ctx, identifier, &result, untypedFetcher)
+ return result, err
+}
+
+// SetWithEncoder sets a value in cache with explicit encoder type.
+func SetWithEncoder[T any](
+ ctx context.Context,
+ cacheManager *CacheManager,
+ encoderType CacheEncoderType,
+ identifier CacheIdentifier,
+ value T,
+ dependencies []CacheIdentifier,
+ ttl time.Duration,
+) error {
+ var serializedData []byte
+ var err error
+
+ // Encode based on type
+ switch encoderType {
+ case CacheEncoderTypeGob:
+ serializedData, err = EncodeGob(value)
+ case CacheEncoderTypeJSON:
+ fallthrough
+ default:
+ serializedData, err = EncodeJSON(value)
+ }
+
+ if err != nil {
+ return fmt.Errorf("failed to encode value: %w", err)
+ }
+
+ // Wrap in CachedItem
+ item := CachedItem{
+ Data: serializedData,
+ RefreshAt: time.Now().Add(ttl / 2).Unix(), // Default soft TTL is half of hard TTL
+ }
+
+ wrapperData, err := json.Marshal(item)
+ if err != nil {
+ return fmt.Errorf("failed to marshal wrapper: %w", err)
+ }
+
+ return cacheManager.Set(ctx, identifier, wrapperData, dependencies, ttl)
+}
diff --git a/typed_manager_test.go b/typed_manager_test.go
new file mode 100644
index 0000000..75ccb52
--- /dev/null
+++ b/typed_manager_test.go
@@ -0,0 +1,314 @@
+package c3e
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/valkey-io/valkey-go"
+)
+
+func TestNewTypedSafeCacheManager(t *testing.T) {
+ mockRepo := newMockCacheRepository()
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ t.Run("creates_typed_manager", func(t *testing.T) {
+ typed := NewTypedSafeCacheManager[testData](safeMgr)
+ if typed == nil {
+ t.Fatal("expected non-nil TypedSafeCacheManager")
+ }
+
+ if typed.manager != safeMgr {
+ t.Error("expected manager to be set")
+ }
+ })
+
+ t.Run("different_types", func(t *testing.T) {
+ typedInt := NewTypedSafeCacheManager[int](safeMgr)
+ typedStr := NewTypedSafeCacheManager[string](safeMgr)
+
+ if typedInt == nil || typedStr == nil {
+ t.Fatal("expected non-nil managers for different types")
+ }
+ })
+}
+
+func TestTypedSafeCacheManager_Get(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Logf("Recovered from panic: %v. This is expected if Valkey is not running.", r)
+ t.Skip("Skipping test because Valkey is not available to create a valid Builder")
+ }
+ }()
+
+ mockRepo := newMockCacheRepository()
+ mockRepo.doFunc = func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ return valkey.ValkeyResult{}
+ }
+ mockRepo.doMultiFunc = func(ctx context.Context, multi ...valkey.Completed) []valkey.ValkeyResult {
+ return make([]valkey.ValkeyResult, len(multi))
+ }
+
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 50 * time.Millisecond,
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ typed := NewTypedSafeCacheManager[testData](safeMgr)
+
+ ctx := context.Background()
+ identifier := CacheIdentifier{Type: "test", ID: "typed1"}
+
+ fetcher := func(ctx context.Context) (testData, []CacheIdentifier, error) {
+ return testData{ID: 100, Name: "Typed Result"}, nil, nil
+ }
+
+ result, err := typed.Get(ctx, identifier, fetcher)
+ if err != nil {
+ t.Fatalf("expected Get to succeed, got error: %v", err)
+ }
+
+ if result.ID != 100 {
+ t.Errorf("expected ID 100, got %d", result.ID)
+ }
+
+ if result.Name != "Typed Result" {
+ t.Errorf("expected Name 'Typed Result', got %q", result.Name)
+ }
+}
+
+func TestTypedSafeCacheManager_GetOrFetch(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Logf("Recovered from panic: %v. This is expected if Valkey is not running.", r)
+ t.Skip("Skipping test because Valkey is not available to create a valid Builder")
+ }
+ }()
+
+ mockRepo := newMockCacheRepository()
+ mockRepo.doFunc = func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ return valkey.ValkeyResult{}
+ }
+ mockRepo.doMultiFunc = func(ctx context.Context, multi ...valkey.Completed) []valkey.ValkeyResult {
+ return make([]valkey.ValkeyResult, len(multi))
+ }
+
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 50 * time.Millisecond,
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ typed := NewTypedSafeCacheManager[testData](safeMgr)
+
+ ctx := context.Background()
+ identifier := CacheIdentifier{Type: "test", ID: "getorfetch1"}
+
+ result, err := typed.GetOrFetch(ctx, identifier, func(ctx context.Context) (testData, []CacheIdentifier, error) {
+ return testData{ID: 200, Name: "Fetched"}, []CacheIdentifier{{Type: "dep", ID: "1"}}, nil
+ })
+
+ if err != nil {
+ t.Fatalf("expected GetOrFetch to succeed, got error: %v", err)
+ }
+
+ if result.ID != 200 {
+ t.Errorf("expected ID 200, got %d", result.ID)
+ }
+}
+
+func TestGetSafe(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Logf("Recovered from panic: %v. This is expected if Valkey is not running.", r)
+ t.Skip("Skipping test because Valkey is not available to create a valid Builder")
+ }
+ }()
+
+ mockRepo := newMockCacheRepository()
+ mockRepo.doFunc = func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ return valkey.ValkeyResult{}
+ }
+ mockRepo.doMultiFunc = func(ctx context.Context, multi ...valkey.Completed) []valkey.ValkeyResult {
+ return make([]valkey.ValkeyResult, len(multi))
+ }
+
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 50 * time.Millisecond,
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ ctx := context.Background()
+ identifier := CacheIdentifier{Type: "test", ID: "getsafe1"}
+
+ fetcher := func(ctx context.Context) (testData, []CacheIdentifier, error) {
+ return testData{ID: 300, Name: "Safe Result"}, nil, nil
+ }
+
+ result, err := GetSafe(ctx, safeMgr, identifier, fetcher)
+ if err != nil {
+ t.Fatalf("expected GetSafe to succeed, got error: %v", err)
+ }
+
+ if result.ID != 300 {
+ t.Errorf("expected ID 300, got %d", result.ID)
+ }
+
+ if result.Name != "Safe Result" {
+ t.Errorf("expected Name 'Safe Result', got %q", result.Name)
+ }
+}
+
+func TestGetSafe_FetcherError(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Logf("Recovered from panic: %v. This is expected if Valkey is not running.", r)
+ t.Skip("Skipping test because Valkey is not available to create a valid Builder")
+ }
+ }()
+
+ mockRepo := newMockCacheRepository()
+ mockRepo.doFunc = func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ return valkey.ValkeyResult{}
+ }
+ mockRepo.doMultiFunc = func(ctx context.Context, multi ...valkey.Completed) []valkey.ValkeyResult {
+ return make([]valkey.ValkeyResult, len(multi))
+ }
+
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 50 * time.Millisecond,
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ ctx := context.Background()
+ identifier := CacheIdentifier{Type: "test", ID: "error1"}
+
+ expectedErr := errors.New("database connection lost")
+ fetcher := func(ctx context.Context) (testData, []CacheIdentifier, error) {
+ return testData{}, nil, expectedErr
+ }
+
+ _, err = GetSafe(ctx, safeMgr, identifier, fetcher)
+ if err == nil {
+ t.Fatal("expected error from fetcher")
+ }
+
+ if !errors.Is(err, expectedErr) {
+ t.Errorf("expected specific error, got %v", err)
+ }
+}
+
+func TestGetOrFetch_Standalone(t *testing.T) {
+ defer func() {
+ if r := recover(); r != nil {
+ t.Logf("Recovered from panic: %v. This is expected if Valkey is not running.", r)
+ t.Skip("Skipping test because Valkey is not available to create a valid Builder")
+ }
+ }()
+
+ mockRepo := newMockCacheRepository()
+ mockRepo.doFunc = func(ctx context.Context, cmd valkey.Completed) valkey.ValkeyResult {
+ return valkey.ValkeyResult{}
+ }
+ mockRepo.doMultiFunc = func(ctx context.Context, multi ...valkey.Completed) []valkey.ValkeyResult {
+ return make([]valkey.ValkeyResult, len(multi))
+ }
+
+ cacheManager, err := NewCacheManager(mockRepo, false)
+ if err != nil {
+ t.Fatalf("failed to create cache manager: %v", err)
+ }
+
+ config := SafeCacheManagerConfig{
+ HardTTL: 2 * time.Hour,
+ SoftTTL: 1 * time.Hour,
+ JitterPercent: 0.1,
+ QueryTimeout: 50 * time.Millisecond,
+ }
+
+ safeMgr, err := NewSafeCacheManager(cacheManager, config)
+ if err != nil {
+ t.Fatalf("failed to create safe cache manager: %v", err)
+ }
+
+ ctx := context.Background()
+ identifier := CacheIdentifier{Type: "test", ID: "standalone1"}
+
+ result, err := GetOrFetch(ctx, safeMgr, identifier, func(ctx context.Context) (testData, []CacheIdentifier, error) {
+ return testData{ID: 400, Name: "Standalone"}, []CacheIdentifier{{Type: "org", ID: "1"}}, nil
+ })
+
+ if err != nil {
+ t.Fatalf("expected GetOrFetch to succeed, got error: %v", err)
+ }
+
+ if result.ID != 400 {
+ t.Errorf("expected ID 400, got %d", result.ID)
+ }
+
+ if result.Name != "Standalone" {
+ t.Errorf("expected Name 'Standalone', got %q", result.Name)
+ }
+}