From 8179acb9d18fb5bb4d1524abdf8afb6e9ecf60b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Gonz=C3=A1lez=20Di=20Antonio?= Date: Sat, 11 Jul 2026 21:34:20 +0200 Subject: [PATCH] feat: import the c3e cache library as a standalone module Extract the dependency-aware caching layer (previously pkg/c3e in svc-qu3ry-core) into its own module, github.com/slashdevops/c3e, so it can be released and consumed as an external dependency. Contents: - Source: CacheManager, SafeCacheManager (stale-while-revalidate, singleflight, TTL jitter, query-timeout fallback), TypedSafeCacheManager, observability hooks, and an injectable slog logger. - Module: go.mod (go 1.26, valkey-go v1.0.76, x/sync v0.22.0) + go.sum. - CI/config: pr/release/codeql workflows (with a Valkey service so the cache tests run for real), .golangci.yaml, .testcoverage.yml, dependabot, funding, release-notes, SECURITY.md. - Docs: split the monolithic README into docs/ (architecture, usage, configuration, observability, best-practices, limitations) with Mermaid diagrams; slimmed the README to install + a quick start + two diagrams + links. Fixed inaccuracies (Go 1.26, valkey v1.0.76, Apache-2.0, dropped the fabricated benchmark table). Fixes uncovered once the suite runs against a real Valkey: - GetWithEncoder built SafeCacheManager as a struct literal, bypassing the constructor, so logger was nil (panic on the first Warn) and QueryTimeout was 0 (an already-expired lookup context forcing every read onto the fallback path). Apply the constructor defaults on that path and add a nil-safe logger accessor. - Made ExampleGetWithEncoder self-contained (it read a JSON-cached key with the Gob decoder) and shrank the stale-while-revalidate integration case from a 65s wall-clock wait to ~2.5s via a short soft TTL. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/FUNDING.yml | 5 + .github/dependabot.yml | 16 + .github/release.yml | 15 + .github/workflows/codeql.yml | 100 ++++++ .github/workflows/pr.yaml | 102 ++++++ .github/workflows/release.yml | 87 +++++ .golangci.yaml | 73 +++++ .testcoverage.yml | 27 ++ README.md | 172 ++++++++++ SECURITY.md | 16 + dep_ttl_test.go | 65 ++++ docs.go | 225 +++++++++++++ docs/README.md | 21 ++ docs/architecture.md | 162 ++++++++++ docs/best-practices.md | 67 ++++ docs/configuration.md | 51 +++ docs/limitations.md | 67 ++++ docs/observability.md | 56 ++++ docs/usage.md | 216 +++++++++++++ example_integration_test.go | 300 ++++++++++++++++++ example_test.go | 351 +++++++++++++++++++++ example_typed_test.go | 313 ++++++++++++++++++ go.mod | 10 + go.sum | 16 + helpers.go | 157 +++++++++ helpers_test.go | 480 ++++++++++++++++++++++++++++ hooks.go | 61 ++++ hooks_test.go | 109 +++++++ invalidation_cascade_test.go | 499 +++++++++++++++++++++++++++++ manager.go | 411 ++++++++++++++++++++++++ manager_test.go | 276 ++++++++++++++++ mock_test.go | 70 +++++ safe_manager.go | 324 +++++++++++++++++++ safe_manager_test.go | 576 ++++++++++++++++++++++++++++++++++ typed_manager.go | 170 ++++++++++ typed_manager_test.go | 314 ++++++++++++++++++ 36 files changed, 5980 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/release.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/pr.yaml create mode 100644 .github/workflows/release.yml create mode 100644 .golangci.yaml create mode 100644 .testcoverage.yml create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 dep_ttl_test.go create mode 100644 docs.go create mode 100644 docs/README.md create mode 100644 docs/architecture.md create mode 100644 docs/best-practices.md create mode 100644 docs/configuration.md create mode 100644 docs/limitations.md create mode 100644 docs/observability.md create mode 100644 docs/usage.md create mode 100644 example_integration_test.go create mode 100644 example_test.go create mode 100644 example_typed_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 helpers.go create mode 100644 helpers_test.go create mode 100644 hooks.go create mode 100644 hooks_test.go create mode 100644 invalidation_cascade_test.go create mode 100644 manager.go create mode 100644 manager_test.go create mode 100644 mock_test.go create mode 100644 safe_manager.go create mode 100644 safe_manager_test.go create mode 100644 typed_manager.go create mode 100644 typed_manager_test.go 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 + +[![Go Reference](https://pkg.go.dev/badge/github.com/slashdevops/c3e.svg)](https://pkg.go.dev/github.com/slashdevops/c3e) +[![Go Version](https://img.shields.io/badge/Go-1.26%2B-00ADD8?style=flat&logo=go)](https://golang.org/) +[![Valkey](https://img.shields.io/badge/Valkey-Compatible-red?style=flat)](https://valkey.io/) +[![CodeQL Advanced](https://github.com/slashdevops/c3e/actions/workflows/codeql.yml/badge.svg)](https://github.com/slashdevops/c3e/actions/workflows/codeql.yml) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](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 +[![CodeQL Advanced](https://github.com/slashdevops/c3e/actions/workflows/codeql.yml/badge.svg)](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) + } +}