From 6398d3c48523ff112a75ce7662d7aff6a8bc62ac Mon Sep 17 00:00:00 2001 From: "F." Date: Sun, 3 May 2026 00:18:06 +0200 Subject: [PATCH 1/8] chore: update dependencies --- go.mod | 17 ++++++++--------- go.sum | 48 ++++++++++++++++++++++-------------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/go.mod b/go.mod index 22570ee..3fe6502 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,11 @@ go 1.26.2 require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/goccy/go-json v0.10.6 - github.com/gofiber/fiber/v3 v3.1.0 - github.com/hyp3rd/ewrap v1.4.0 - github.com/hyp3rd/sectools v1.2.4 + github.com/gofiber/fiber/v3 v3.2.0 + github.com/hyp3rd/ewrap v1.5.0 + github.com/hyp3rd/sectools v1.2.5 github.com/longbridgeapp/assert v1.1.0 - github.com/redis/go-redis/v9 v9.18.0 + github.com/redis/go-redis/v9 v9.19.0 github.com/ugorji/go/codec v1.3.1 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 @@ -19,13 +19,12 @@ require ( require ( github.com/andybalholm/brotli v1.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/gofiber/schema v1.7.0 // indirect - github.com/gofiber/utils/v2 v2.0.2 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.4 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/go.sum b/go.sum index e2ceb8e..36cdfd9 100644 --- a/go.sum +++ b/go.sum @@ -8,34 +8,32 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= -github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= -github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= -github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= -github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= -github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= +github.com/gofiber/fiber/v3 v3.2.0 h1:g9+09D320foINPpCnR3ibQ5oBEFHjAWRRfDG1te54u8= +github.com/gofiber/fiber/v3 v3.2.0/go.mod h1:FHOsc2Db7HhHpsE62QAaJlXVV1pNkbZEptZ4jtti7m4= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec= +github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4= 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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hyp3rd/ewrap v1.4.0 h1:RyxyYKDP8HLVDreWw+g89yTZfJjTNRcmrP3tiKQjZK4= -github.com/hyp3rd/ewrap v1.4.0/go.mod h1:ob+oREpgZ9Bonq5C1tViaqBtWmh1H5EFWc9Bfn1lPB0= -github.com/hyp3rd/sectools v1.2.4 h1:ZfboELIs57xbs8j5lP/jkwh+zhnoeWGfgpX5nCuF7WA= -github.com/hyp3rd/sectools v1.2.4/go.mod h1:H+0Mjhn3sTV6F4iFvoeVjUT/60MXOYqOMyuV7TG8YNI= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/hyp3rd/ewrap v1.5.0 h1:jXNEO1u6IIXGMg7DktAk3wXheGYF5tAxB7YhHW4lIDw= +github.com/hyp3rd/ewrap v1.5.0/go.mod h1:N3C08pcvWgJxXIzn3GqWYQhOh7Yvy5je7HoNTy4qlLI= +github.com/hyp3rd/sectools v1.2.5 h1:i3uyCA5jElfMwYPe0YQvPyDMSJIlKFMTgaqjsWd53ok= +github.com/hyp3rd/sectools v1.2.5/go.mod h1:6olmYYaZFgHz6fLgv/XZf/kePquYUWIyfC6TeyJvWXg= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -47,20 +45,18 @@ github.com/longbridgeapp/assert v1.1.0 h1:L+/HISOhuGbNAAmJNXgk3+Tm5QmSB70kwdktJX github.com/longbridgeapp/assert v1.1.0/go.mod h1:UOI7O3rzlzlz715lQm0atWs6JbrYGuIJUEeOekutL6o= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= -github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= @@ -75,8 +71,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= From 8f49287f3ee8e7f9ce36ab00416a39ae67dfcd94 Mon Sep 17 00:00:00 2001 From: "F." Date: Sun, 3 May 2026 00:42:35 +0200 Subject: [PATCH 2/8] chore: modernize CI workflows and centralize HTTP error constants Upgrade GitHub Actions tooling: - Promote CodeQL workflow to Advanced setup with multi-language support (actions, go, ruby) and explicit build-mode per language - Replace manual golangci-lint curl install with golangci/golangci-lint-action in the lint workflow - Remove the redundant pre-commit CI workflow - Bump golangci-lint to v2.12.1 and buf to v1.69.0 across project settings, Makefile, and pre-commit hook Introduce internal/constants/errors.go to centralize HTTP JSON error labels and messages (ErrorLabel, ErrMegMissingCacheKey, ErrMsgUnsupportedDistributedBackend), replacing inline string literals scattered across management_http.go, pkg/backend/dist_http_server.go, and related tests. Apply minor whitespace formatting improvements across multiple source files to satisfy linter rules. --- .github/workflows/codeql.yml | 91 ++++++++++++++++++++---------- .github/workflows/lint.yml | 24 +++++--- .github/workflows/pre-commit.yml | 48 ---------------- .golangci.yaml | 12 ++-- .pre-commit/golangci-lint-hook | 2 +- .project-settings.env | 4 +- Makefile | 5 +- hypercache.go | 6 ++ internal/constants/errors.go | 10 ++++ management_http.go | 15 ++--- pkg/backend/dist_http_server.go | 22 ++++---- pkg/backend/dist_http_transport.go | 1 + pkg/backend/dist_memory.go | 9 +++ pkg/backend/inmemory.go | 2 + pkg/backend/redis.go | 1 + pkg/cache/cmap.go | 3 + pkg/cache/v2/cmap.go | 5 ++ pkg/stats/histogramcollector.go | 3 + tests/hypercache_mgmt_dist_test.go | 8 +-- 19 files changed, 154 insertions(+), 117 deletions(-) delete mode 100644 .github/workflows/pre-commit.yml create mode 100644 internal/constants/errors.go diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6dd0404..04e9004 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -10,65 +10,96 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: "CodeQL Advanced" on: push: - branches: ["main"] + branches: [ "main" ] pull_request: - # The branches below must be a subset of the branches above - branches: ["main"] + branches: [ "main" ] schedule: - - cron: "33 23 * * 3" + - cron: "31 1 * * 4" + +permissions: + contents: read jobs: analyze: - name: Analyze - runs-on: ubuntu-latest + 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 - security-events: write strategy: fail-fast: false matrix: - language: ["go"] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Use only 'java' to analyze code written in Java, Kotlin or both - # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both - # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support - + include: + - language: actions + build-mode: none + - language: go + build-mode: autobuild + - language: ruby + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', '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@v6 + uses: actions/checkout@v4 + + # 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. - # 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 + # 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 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v4 - - # â„šī¸ 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 the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh + # 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 + - name: Run manual build steps + 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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1cad406..520c3e2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,8 +4,9 @@ name: lint on: pull_request: push: - branches: [main] - + branches: [ main ] +permissions: + contents: read jobs: lint: runs-on: ubuntu-latest @@ -18,7 +19,7 @@ jobs: source .project-settings.env set +a echo "go_version=${GO_VERSION}" >> "$GITHUB_OUTPUT" - echo "gci_prefix=${GCI_PREFIX:-github.com/hyp3rd/hypercache}" >> "$GITHUB_OUTPUT" + echo "gci_prefix=${GCI_PREFIX:-github.com/hyp3rd/ewrap}" >> "$GITHUB_OUTPUT" echo "golangci_lint_version=${GOLANGCI_LINT_VERSION}" >> "$GITHUB_OUTPUT" echo "proto_enabled=${PROTO_ENABLED:-true}" >> "$GITHUB_OUTPUT" - name: Setup Go @@ -32,7 +33,8 @@ jobs: path: | ~/go/pkg/mod ~/.cache/go-build - key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{ hashFiles('**/go.sum') }} + key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{ + hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}- - name: Install tools @@ -40,7 +42,6 @@ jobs: go install github.com/daixiang0/gci@latest go install mvdan.cc/gofumpt@latest go install honnef.co/go/tools/cmd/staticcheck@latest - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" "${{ steps.settings.outputs.golangci_lint_version }}" - name: Modules run: go mod download - name: Tidy check @@ -48,10 +49,17 @@ jobs: go mod tidy git diff --exit-code go.mod go.sum - name: gci - run: gci write -s standard -s default -s blank -s dot -s "prefix(${{ steps.settings.outputs.gci_prefix }})" -s localmodule --skip-vendor --skip-generated $(find . -type f -name '*.go' -not -path "./pkg/api/*" -not -path "./vendor/*" -not -path "./.gocache/*" -not -path "./.git/*") + run: gci write -s standard -s default -s blank -s dot -s "prefix(${{ + steps.settings.outputs.gci_prefix }})" -s localmodule --skip-vendor + --skip-generated $(find . -type f -name '*.go' -not -path + "./pkg/api/*" -not -path "./vendor/*" -not -path "./.gocache/*" -not + -path "./.git/*") - name: gofumpt - run: gofumpt -l -w $(find . -type f -name '*.go' -not -path "./pkg/api/*" -not -path "./vendor/*" -not -path "./.gocache/*" -not -path "./.git/*") + run: gofumpt -l -w $(find . -type f -name '*.go' -not -path "./pkg/api/*" -not + -path "./vendor/*" -not -path "./.gocache/*" -not -path "./.git/*") - name: staticcheck run: staticcheck ./... - name: golangci-lint - run: golangci-lint run -v ./... + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 + # with: + # version: "${{ steps.settings.outputs.golangci_lint_version }}" diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index 39b1240..0000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: pre-commit - -on: - pull_request: - push: - branches: [main] - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - name: Load project settings - id: settings - run: | - set -a - source .project-settings.env - set +a - echo "go_version=${GO_VERSION}" >> "$GITHUB_OUTPUT" - echo "golangci_lint_version=${GOLANGCI_LINT_VERSION}" >> "$GITHUB_OUTPUT" - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.x" - - name: Setup Go - uses: actions/setup-go@v6 - with: - go-version: "${{ steps.settings.outputs.go_version }}" - check-latest: true - - name: Cache Go modules - uses: actions/cache@v5 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}- - - name: Install pre-commit - run: pip install pre-commit - - name: Install Go tools for hooks - run: | - go install github.com/daixiang0/gci@latest - go install mvdan.cc/gofumpt@latest - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" "${{ steps.settings.outputs.golangci_lint_version }}" - - name: Run pre-commit - run: pre-commit run --config .pre-commit-ci-config.yaml --all-files diff --git a/.golangci.yaml b/.golangci.yaml index 7aa1a6f..0121bfe 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -49,10 +49,12 @@ linters: # https://golangci-lint.run/usage/linters/#enabled-by-default default: all enable: + - gomodguard_v2 - wsl_v5 disable: - exhaustruct - depguard + - gomodguard - ireturn - lll - tagliatelle @@ -126,23 +128,23 @@ linters: disabled: false arguments: - max-lit-count: "3" - allow-strs: '"","get","path","key"' + allow-strs: "\"\",\"get\",\"path\",\"key\"" allow-ints: "-1,0,1,2,10" allow-floats: "0.0,1.0" - name: cognitive-complexity severity: warning disabled: false - arguments: [15] + arguments: [ 15 ] - name: cyclomatic - arguments: [15] + arguments: [ 15 ] # - name: function-length # arguments: [80, 0] - name: max-public-structs - arguments: [10] + arguments: [ 10 ] - name: nested-structs disabled: true @@ -151,7 +153,7 @@ linters: disabled: true - name: line-length-limit - arguments: [160] + arguments: [ 160 ] - name: var-naming disabled: true diff --git a/.pre-commit/golangci-lint-hook b/.pre-commit/golangci-lint-hook index 86718b2..d3e82ea 100755 --- a/.pre-commit/golangci-lint-hook +++ b/.pre-commit/golangci-lint-hook @@ -23,7 +23,7 @@ if [[ -f "${ROOT_DIR}/.project-settings.env" ]]; then # shellcheck disable=SC1090 source "${ROOT_DIR}/.project-settings.env" fi -GOLANGCI_LINT_VERSION="${GOLANGCI_LINT_VERSION:-v2.11.4}" +GOLANGCI_LINT_VERSION="${GOLANGCI_LINT_VERSION:-v2.12.1}" # ####################################### # Install dependencies to run the pre-commit hook diff --git a/.project-settings.env b/.project-settings.env index 195eeaa..7f0d2b5 100644 --- a/.project-settings.env +++ b/.project-settings.env @@ -1,5 +1,5 @@ -GOLANGCI_LINT_VERSION=v2.11.4 -BUF_VERSION=v1.67.0 +GOLANGCI_LINT_VERSION=v2.12.1 +BUF_VERSION=v1.69.0 GO_VERSION=1.26.2 GCI_PREFIX=github.com/hyp3rd/hypercache PROTO_ENABLED=false diff --git a/Makefile b/Makefile index 29aff67..ddc5e5e 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ include .project-settings.env -GOLANGCI_LINT_VERSION ?= v2.11.4 -BUF_VERSION ?= v1.67.0 +GOLANGCI_LINT_VERSION ?= v2.12.1 +BUF_VERSION ?= v1.69.0 GO_VERSION ?= 1.26.2 GCI_PREFIX ?= github.com/hyp3rd/hypercache PROTO_ENABLED ?= true @@ -166,4 +166,5 @@ help: @echo @echo @echo "For more information, see the project README." + .PHONY: init prepare-toolchain prepare-base-tools update-toolchain test bench vet update-deps lint sec help diff --git a/hypercache.go b/hypercache.go index fbf7680..a3eeaf7 100644 --- a/hypercache.go +++ b/hypercache.go @@ -384,6 +384,7 @@ func (hyperCache *HyperCache[T]) startBackgroundJobs(ctx context.Context) { func (hyperCache *HyperCache[T]) startExpirationRoutine(ctx context.Context) { go func() { var tick *time.Ticker + if hyperCache.expirationInterval > 0 { tick = time.NewTicker(hyperCache.expirationInterval) } @@ -399,6 +400,7 @@ func (hyperCache *HyperCache[T]) startExpirationRoutine(ctx context.Context) { // handleExpirationSelect processes one select iteration; returns true if caller should exit. func (hyperCache *HyperCache[T]) handleExpirationSelect(ctx context.Context, tick *time.Ticker) bool { var tickC <-chan time.Time + if tick != nil { tickC = tick.C } @@ -411,6 +413,7 @@ func (hyperCache *HyperCache[T]) handleExpirationSelect(ctx context.Context, tic // manual/coalesced trigger hyperCache.expirationLoop(ctx) hyperCache.expirationSignalPending.Store(false) + // drain any queued triggers quickly for draining := true; draining; { select { @@ -774,6 +777,7 @@ func (hyperCache *HyperCache[T]) GetOrSet(ctx context.Context, key string, value hyperCache.mutex.Lock() hyperCache.evictionAlgorithm.Set(key, item.Value) hyperCache.mutex.Unlock() + // If the cache is at capacity, evict an item when the eviction interval is zero if hyperCache.shouldEvict.Load() && hyperCache.backend.Count(ctx) > hyperCache.backend.Capacity() { hyperCache.evictItem(ctx) @@ -805,6 +809,7 @@ func (hyperCache *HyperCache[T]) GetMultiple(ctx context.Context, keys ...string hyperCache.execTriggerExpiration() } else { hyperCache.touchItem(ctx, key, item) // Update the last access time and access count + // Add the item to the result map result[key] = item.Value } @@ -934,6 +939,7 @@ func (hyperCache *HyperCache[T]) SetCapacity(ctx context.Context, capacity int) hyperCache.backend.SetCapacity(capacity) // evaluate again if the cache should evict items proactively hyperCache.shouldEvict.Swap(hyperCache.evictionInterval == 0 && hyperCache.backend.Capacity() > 0) + // if the cache size is greater than the new capacity, evict items if hyperCache.backend.Count(ctx) > hyperCache.Capacity() { hyperCache.evictionLoop(ctx) diff --git a/internal/constants/errors.go b/internal/constants/errors.go new file mode 100644 index 0000000..48c3e90 --- /dev/null +++ b/internal/constants/errors.go @@ -0,0 +1,10 @@ +package constants + +const ( + // ErrorLabel is a common label for all errors in HyperCache. + ErrorLabel = "error" + // ErrMegMissingCacheKey is returned when cache key is missing in request. + ErrMegMissingCacheKey = "missing cache key" + // ErrMsgUnsupportedDistributedBackend is returned when the distributed backend does not support the requested operation. + ErrMsgUnsupportedDistributedBackend = "distributed backend unsupported" +) diff --git a/management_http.go b/management_http.go index bdc446a..5dc8edd 100644 --- a/management_http.go +++ b/management_http.go @@ -8,6 +8,7 @@ import ( fiber "github.com/gofiber/fiber/v3" "github.com/hyp3rd/ewrap" + "github.com/hyp3rd/hypercache/internal/constants" "github.com/hyp3rd/hypercache/internal/sentinel" "github.com/hyp3rd/hypercache/pkg/stats" ) @@ -216,19 +217,19 @@ func (s *ManagementHTTPServer) registerDistributed(useAuth func(fiber.Handler) f if dist, ok := hc.(managementCacheDistOpt); ok { m := dist.DistMetrics() if m == nil { - return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "dist metrics not available"}) + return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{constants.ErrorLabel: "dist metrics not available"}) } return fiberCtx.JSON(m) } - return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "distributed backend unsupported"}) + return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgUnsupportedDistributedBackend}) })) s.app.Get("/dist/owners", useAuth(func(fiberCtx fiber.Ctx) error { if dist, ok := hc.(managementCacheDistOpt); ok { key := fiberCtx.Query("key") if key == "" { - return fiberCtx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing key"}) + return fiberCtx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) } owners := dist.ClusterOwners(key) @@ -236,7 +237,7 @@ func (s *ManagementHTTPServer) registerDistributed(useAuth func(fiber.Handler) f return fiberCtx.JSON(fiber.Map{"key": key, "owners": owners}) } - return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "distributed backend unsupported"}) + return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgUnsupportedDistributedBackend}) })) } @@ -248,7 +249,7 @@ func (s *ManagementHTTPServer) registerCluster(useAuth func(fiber.Handler) fiber return fiberCtx.JSON(fiber.Map{"replication": replication, "virtualNodes": vnodes, "members": members}) } - return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "distributed backend unsupported"}) + return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgUnsupportedDistributedBackend}) })) s.app.Get("/cluster/ring", useAuth(func(fiberCtx fiber.Ctx) error { if mi, ok := hc.(membershipIntrospect); ok { @@ -257,14 +258,14 @@ func (s *ManagementHTTPServer) registerCluster(useAuth func(fiber.Handler) fiber return fiberCtx.JSON(fiber.Map{"count": len(spots), "vnodes": spots}) } - return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "distributed backend unsupported"}) + return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgUnsupportedDistributedBackend}) })) s.app.Get("/cluster/heartbeat", useAuth(func(fiberCtx fiber.Ctx) error { // heartbeat metrics if mi, ok := hc.(membershipIntrospect); ok { return fiberCtx.JSON(mi.DistHeartbeatMetrics()) } - return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "distributed backend unsupported"}) + return fiberCtx.Status(fiber.StatusNotFound).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgUnsupportedDistributedBackend}) })) } diff --git a/pkg/backend/dist_http_server.go b/pkg/backend/dist_http_server.go index 54d686b..15b811b 100644 --- a/pkg/backend/dist_http_server.go +++ b/pkg/backend/dist_http_server.go @@ -10,6 +10,7 @@ import ( fiber "github.com/gofiber/fiber/v3" "github.com/hyp3rd/ewrap" + "github.com/hyp3rd/hypercache/internal/constants" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -52,7 +53,7 @@ func (s *distHTTPServer) registerSet(ctx context.Context, dm *DistMemory) { //no unmarshalErr := json.Unmarshal(body, &req) if unmarshalErr != nil { // separated to satisfy noinlineerr - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": unmarshalErr.Error()}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: unmarshalErr.Error()}) } it := &cache.Item{ // LastUpdated set to now for replicated writes @@ -77,7 +78,7 @@ func (s *distHTTPServer) registerSet(ctx context.Context, dm *DistMemory) { //no unmarshalErr := json.Unmarshal(body, &req) if unmarshalErr != nil { // separated to satisfy noinlineerr - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": unmarshalErr.Error()}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: unmarshalErr.Error()}) } it := &cache.Item{ // LastUpdated set to now for replicated writes @@ -100,12 +101,12 @@ func (s *distHTTPServer) registerGet(_ context.Context, dm *DistMemory) { //noli s.app.Get("/internal/cache/get", func(fctx fiber.Ctx) error { key := fctx.Query("key") if key == "" { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing key"}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) } owners := dm.lookupOwners(key) if len(owners) == 0 { - return fctx.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "not owner"}) + return fctx.Status(fiber.StatusNotFound).JSON(fiber.Map{constants.ErrorLabel: "not owner"}) } if it, ok := dm.shardFor(key).items.Get(key); ok { @@ -119,12 +120,12 @@ func (s *distHTTPServer) registerGet(_ context.Context, dm *DistMemory) { //noli s.app.Get("/internal/get", func(fctx fiber.Ctx) error { key := fctx.Query("key") if key == "" { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing key"}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) } owners := dm.lookupOwners(key) if len(owners) == 0 { - return fctx.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "not owner"}) + return fctx.Status(fiber.StatusNotFound).JSON(fiber.Map{constants.ErrorLabel: "not owner"}) } if it, ok := dm.shardFor(key).items.Get(key); ok { @@ -140,12 +141,12 @@ func (s *distHTTPServer) registerRemove(ctx context.Context, dm *DistMemory) { / s.app.Delete("/internal/cache/remove", func(fctx fiber.Ctx) error { key := fctx.Query("key") if key == "" { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing key"}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) } replicate, parseErr := strconv.ParseBool(fctx.Query("replicate", "false")) if parseErr != nil { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid replicate"}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: "invalid replicate"}) } dm.applyRemove(ctx, key, replicate) @@ -157,12 +158,12 @@ func (s *distHTTPServer) registerRemove(ctx context.Context, dm *DistMemory) { / s.app.Delete("/internal/del", func(fctx fiber.Ctx) error { key := fctx.Query("key") if key == "" { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing key"}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) } replicate, parseErr := strconv.ParseBool(fctx.Query("replicate", "false")) if parseErr != nil { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid replicate"}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: "invalid replicate"}) } dm.applyRemove(ctx, key, replicate) @@ -189,6 +190,7 @@ func (s *distHTTPServer) registerMerkle(_ context.Context, dm *DistMemory) { //n // naive keys listing for anti-entropy (testing only). Not efficient for large datasets. s.app.Get("/internal/keys", func(fctx fiber.Ctx) error { var keys []string + for _, shard := range dm.shards { if shard == nil { continue diff --git a/pkg/backend/dist_http_transport.go b/pkg/backend/dist_http_transport.go index 0a86f44..af984ed 100644 --- a/pkg/backend/dist_http_transport.go +++ b/pkg/backend/dist_http_transport.go @@ -159,6 +159,7 @@ func decodeGetBody(r io.Reader) (*cache.Item, bool, error) { //nolint:ireturn } var found bool + if fb, ok := raw["found"]; ok { err := json.Unmarshal(fb, &found) if err != nil { diff --git a/pkg/backend/dist_memory.go b/pkg/backend/dist_memory.go index dbf1af9..4f37dad 100644 --- a/pkg/backend/dist_memory.go +++ b/pkg/backend/dist_memory.go @@ -357,6 +357,7 @@ func (mt *MerkleTree) DiffLeafRanges(other *MerkleTree) []int { //nolint:ireturn } var diffs []int + for i := range mt.LeafHashes { if !equalBytes(mt.LeafHashes[i], other.LeafHashes[i]) { diffs = append(diffs, i) @@ -595,6 +596,7 @@ func NewDistMemoryWithConfig(ctx context.Context, cfg any, opts ...DistMemoryOpt } var mc minimalConfig + if asserted, ok := cfg.(minimalConfig); ok { // best-effort copy mc = asserted } @@ -1388,6 +1390,7 @@ func (dm *DistMemory) compactTombstones() int64 { //nolint:ireturn now := time.Now() var purged int64 + for _, sh := range dm.shards { if sh == nil { continue @@ -1408,6 +1411,7 @@ func (dm *DistMemory) compactTombstones() int64 { //nolint:ireturn // countTombstones returns approximate current count. func (dm *DistMemory) countTombstones() int64 { //nolint:ireturn var total int64 + for _, sh := range dm.shards { if sh == nil { continue @@ -1637,6 +1641,7 @@ func (*DistMemory) computeNewReplicas(sh *distShard, key string, owners []cluste } var out []cluster.NodeID + for _, o := range owners[1:] { if _, ok := prevSet[o]; !ok { out = append(out, o) @@ -1808,6 +1813,7 @@ func (dm *DistMemory) shedShard(sh *distShard, now time.Time) { //nolint:ireturn sh.removedAtMu.Lock() var dels []string + for k, at := range sh.removedAt { if now.Sub(at) >= grace { dels = append(dels, k) @@ -2370,6 +2376,7 @@ func (dm *DistMemory) replayHints(ctx context.Context) { // reduced cognitive co out = append(out, hintEntry) case 1: dm.adjustHintAccounting(-1, -hintEntry.size) + default: // defensive future-proofing out = append(out, hintEntry) } @@ -2517,6 +2524,7 @@ func (dm *DistMemory) runGossipTick() { //nolint:ireturn } var candidates []*cluster.Node + for _, n := range peers { if n.ID != dm.localNode.ID { candidates = append(candidates, n) @@ -2861,6 +2869,7 @@ func (dm *DistMemory) applyRemove(ctx context.Context, key string, replicate boo sh := dm.shardFor(key) // capture version from existing item (if any) and increment for tombstone var nextVer uint64 + if it, ok := sh.items.Get(key); ok && it != nil { ci := it // already *cache.Item (ConcurrentMap stores *cache.Item) diff --git a/pkg/backend/inmemory.go b/pkg/backend/inmemory.go index 17d5340..9a040e0 100644 --- a/pkg/backend/inmemory.go +++ b/pkg/backend/inmemory.go @@ -26,6 +26,7 @@ func NewInMemory(opts ...Option[InMemory]) (IBackend[InMemory], error) { } // Apply the backend options ApplyOptions(backendInstance, opts...) + // Check if the `capacity` is valid if backendInstance.capacity < 0 { return nil, sentinel.ErrInvalidCapacity @@ -142,6 +143,7 @@ func (cacheBackend *InMemory) Clear(ctx context.Context) error { go func() { defer close(done) + // clear the cacheBackend cacheBackend.items.Clear() }() diff --git a/pkg/backend/redis.go b/pkg/backend/redis.go index d371730..81dd9b5 100644 --- a/pkg/backend/redis.go +++ b/pkg/backend/redis.go @@ -54,6 +54,7 @@ func NewRedis(redisOptions ...Option[Redis]) (IBackend[Redis], error) { // Check if the serializer is nil if rb.Serializer == nil { var err error + // Set a the serializer to default to `msgpack` rb.Serializer, err = serializer.New("msgpack") if err != nil { diff --git a/pkg/cache/cmap.go b/pkg/cache/cmap.go index 1d160e2..7eac1f0 100644 --- a/pkg/cache/cmap.go +++ b/pkg/cache/cmap.go @@ -131,6 +131,7 @@ func (m ConcurrentMap[K, V]) Get(key K) (V, bool) { // Get shard shard := m.GetShard(key) shard.RLock() + // Get item from shard. val, ok := shard.items[key] shard.RUnlock() @@ -158,6 +159,7 @@ func (m ConcurrentMap[K, V]) Has(key K) bool { // Get shard shard := m.GetShard(key) shard.RLock() + // See if element is within shard. _, ok := shard.items[key] shard.RUnlock() @@ -270,6 +272,7 @@ func snapshot[Key comparable, Val any](cmap ConcurrentMap[Key, Val]) []chan Tupl chans := make([]chan Tuple[Key, Val], ShardCount) wg := sync.WaitGroup{} wg.Add(ShardCount) + // Foreach shard. for index, shard := range cmap.shards { go func(index int, shard *ConcurrentMapShared[Key, Val]) { diff --git a/pkg/cache/v2/cmap.go b/pkg/cache/v2/cmap.go index 4261b19..a4c4173 100644 --- a/pkg/cache/v2/cmap.go +++ b/pkg/cache/v2/cmap.go @@ -81,6 +81,7 @@ func getShardIndex(key string) uint32 { ) var sum uint32 = fnvOffset32 + for i := range key { // Go 1.22+ integer range over string indices sum ^= uint32(key[i]) @@ -105,6 +106,7 @@ func (cm *ConcurrentMap) Get(key string) (*Item, bool) { // Get shard shard := cm.GetShard(key) shard.RLock() + // Get item from shard. item, ok := shard.items[key] shard.RUnlock() @@ -154,6 +156,7 @@ func (cm *ConcurrentMap) Has(key string) bool { // Get shard shard := cm.GetShard(key) shard.RLock() + // Get item from shard. _, ok := shard.items[key] shard.RUnlock() @@ -213,11 +216,13 @@ func snapshot(cm *ConcurrentMap) []chan Tuple { chans := make([]chan Tuple, ShardCount) wg := sync.WaitGroup{} wg.Add(ShardCount) + // Foreach shard. for index, shard := range cm.shards { go func(index int, shard *ConcurrentMapShard) { // Foreach key, value pair. shard.RLock() + // Determine capacity and copy to a local slice to shorten lock hold time. n := len(shard.items) diff --git a/pkg/stats/histogramcollector.go b/pkg/stats/histogramcollector.go index 3196218..136111a 100644 --- a/pkg/stats/histogramcollector.go +++ b/pkg/stats/histogramcollector.go @@ -69,6 +69,7 @@ func (c *HistogramStatsCollector) Mean(stat constants.Stat) float64 { } var sum int64 + for _, value := range values { sum += value } @@ -142,6 +143,7 @@ func (c *HistogramStatsCollector) GetStats() Stats { // sum returns the sum of a set of values. func sum(values []int64) int64 { var sum int64 + for _, value := range values { sum += value } @@ -156,6 +158,7 @@ func variance(values []int64, mean float64) float64 { } var variance float64 + for _, value := range values { variance += math.Pow(float64(value)-mean, 2) } diff --git a/tests/hypercache_mgmt_dist_test.go b/tests/hypercache_mgmt_dist_test.go index 49ebee9..8f76800 100644 --- a/tests/hypercache_mgmt_dist_test.go +++ b/tests/hypercache_mgmt_dist_test.go @@ -57,7 +57,7 @@ func TestManagementHTTPDistMemory(t *testing.T) { //nolint:paralleltest metricsBody := getJSON(t, baseURL+"/dist/metrics") if _, ok := metricsBody["ForwardGet"]; !ok { // one exported field // could be 404 if backend unsupported (should not be here) - if e, hasErr := metricsBody["error"]; hasErr { + if e, hasErr := metricsBody[constants.ErrorLabel]; hasErr { t.Fatalf("/dist/metrics returned error: %v", e) } @@ -68,7 +68,7 @@ func TestManagementHTTPDistMemory(t *testing.T) { //nolint:paralleltest // /dist/owners ownersBody := getJSON(t, baseURL+"/dist/owners?key=alpha") if _, ok := ownersBody["owners"]; !ok { - if e, hasErr := ownersBody["error"]; hasErr { + if e, hasErr := ownersBody[constants.ErrorLabel]; hasErr { t.Fatalf("/dist/owners returned error: %v", e) } @@ -78,7 +78,7 @@ func TestManagementHTTPDistMemory(t *testing.T) { //nolint:paralleltest // /cluster/members membersBody := getJSON(t, baseURL+"/cluster/members") if _, ok := membersBody["members"]; !ok { - if e, hasErr := membersBody["error"]; hasErr { + if e, hasErr := membersBody[constants.ErrorLabel]; hasErr { t.Fatalf("/cluster/members returned error: %v", e) } @@ -88,7 +88,7 @@ func TestManagementHTTPDistMemory(t *testing.T) { //nolint:paralleltest // /cluster/ring ringBody := getJSON(t, baseURL+"/cluster/ring") if _, ok := ringBody["vnodes"]; !ok { - if e, hasErr := ringBody["error"]; hasErr { + if e, hasErr := ringBody[constants.ErrorLabel]; hasErr { t.Fatalf("/cluster/ring returned error: %v", e) } From 36ad5ab1fcd12800866d0a3071069b16c56e81a4 Mon Sep 17 00:00:00 2001 From: "F." Date: Sun, 3 May 2026 01:40:02 +0200 Subject: [PATCH 3/8] test(bench): add regression yardsticks and harden CI quality gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add benchmark suites for ConcurrentMap, HistogramStatsCollector, and HyperCache concurrency paths as regression yardsticks ahead of planned performance optimizations (Phases 1–2). Capture an initial bench-baseline.txt on Apple M4 Pro for benchstat comparison. Harden CI and tooling: - Upgrade test-race to -count=10 -shuffle=on -timeout=15m in both Makefile and GitHub Actions workflow for stronger flake detection - Enable golangci-lint on test files (tests: true) - Add typecheck (go vet), build, and ci aggregate Makefile targets - Add bench-baseline capture target for ongoing benchstat comparisons - Remove stale golangci-lint badge link from README --- .github/workflows/test.yml | 7 +- .golangci.yaml | 43 ++++-- Makefile | 26 +++- README.md | 1 - bench-baseline.txt | Bin 0 -> 5149 bytes pkg/backend/dist_memory.go | 16 +-- pkg/cache/v2/cmap_bench_test.go | 99 +++++++++++++ pkg/cache/v2/cmap_test.go | 1 + pkg/eviction/arc_test.go | 1 + pkg/eviction/cawolfu_test.go | 2 + pkg/eviction/clock_test.go | 1 + pkg/stats/histogramcollector_bench_test.go | 67 +++++++++ pool_test.go | 1 + .../hypercache_concurrency_benchmark_test.go | 133 ++++++++++++++++++ .../hypercache_get_benchmark_test.go | 4 +- .../hypercache_list_benchmark_test.go | 2 +- .../hypercache_set_benchmark_test.go | 4 +- tests/hypercache_distmemory_hint_caps_test.go | 1 + .../hypercache_distmemory_integration_test.go | 1 + ...cache_distmemory_remove_readrepair_test.go | 1 + tests/hypercache_http_merkle_test.go | 13 +- tests/hypercache_mgmt_dist_test.go | 1 + .../integration/dist_rebalance_leave_test.go | 13 +- tests/merkle_delete_tombstone_test.go | 4 +- tests/merkle_empty_tree_test.go | 4 +- tests/merkle_no_diff_test.go | 4 +- tests/merkle_single_missing_key_test.go | 4 +- tests/merkle_sync_test.go | 4 +- tests/port_helper.go | 36 +++++ 29 files changed, 439 insertions(+), 55 deletions(-) create mode 100644 bench-baseline.txt create mode 100644 pkg/cache/v2/cmap_bench_test.go create mode 100644 pkg/stats/histogramcollector_bench_test.go create mode 100644 tests/benchmark/hypercache_concurrency_benchmark_test.go create mode 100644 tests/port_helper.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 62a7104..f5a05a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,8 +34,11 @@ jobs: ${{ runner.os }}-go-${{ steps.settings.outputs.go_version }}- - name: Modules run: go mod download - - name: Test (race + coverage) - run: RUN_INTEGRATION_TEST=yes go test -race -coverprofile=coverage.out ./... + - name: Test (race + shuffle + repeated) + run: > + RUN_INTEGRATION_TEST=yes + go test -race -count=10 -shuffle=on -timeout=15m + -coverprofile=coverage.out ./... - name: Upload coverage artifact uses: actions/upload-artifact@v6 with: diff --git a/.golangci.yaml b/.golangci.yaml index 0121bfe..e8bec40 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -59,6 +59,12 @@ linters: - lll - tagliatelle - wsl + # testpackage: project consistently uses internal _test packages + # (access to unexported helpers without re-exporting). Documented choice. + - testpackage + # paralleltest: tracked separately; many tests share a cache instance and + # cannot be parallelized without refactoring (see Phase 1). + - paralleltest settings: cyclop: @@ -70,10 +76,10 @@ linters: # Such cases aren't reported by default. # Default: false check-type-assertions: true - # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`. - # Such cases aren't reported by default. - # Default: false - check-blank: true + # check-blank flagged `_ = f()` which is the explicit "I read this, I'm + # ignoring it" idiom. Disabled — silent error drops (`f()` with no `_`) + # are still caught by the default errcheck pass. + 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 @@ -84,7 +90,7 @@ linters: - fmt.Fprintf - fmt.Fprintln funlen: - lines: 100 + lines: 150 lll: # Max line length, lines longer will be reported. # '\t' is counted as 1 character by default, and can be changed with the tab-width option. @@ -100,6 +106,13 @@ linters: - "0755" - "2" + goconst: + # Default: 3 — too aggressive for tests where short fixture strings ("A", + # "B", "v") naturally repeat. Bumped so it surfaces real duplication only. + min-occurrences: 5 + # Default: 3 — single-character literals are rarely worth a constant. + min-len: 3 + ireturn: # ireturn does not allow using `allow` and `reject` settings at the same time. # Both settings are lists of the keywords and regular expressions matched to interface or package names. @@ -123,14 +136,16 @@ linters: revive: enable-all-rules: true rules: + # add-constant: paranoid — fires on every literal in tests (port numbers, + # iteration counts, fixture strings). Keeping it on creates more nolints + # than insight. Re-enable per-package if it provides real signal there. - name: add-constant - severity: warning - disabled: false - arguments: - - max-lit-count: "3" - allow-strs: "\"\",\"get\",\"path\",\"key\"" - allow-ints: "-1,0,1,2,10" - allow-floats: "0.0,1.0" + disabled: true + + # unnecessary-format: pedantic. `t.Fatalf("msg")` -> `t.Fatal("msg")` is + # a stylistic preference, not a defect. + - name: unnecessary-format + disabled: true - name: cognitive-complexity severity: warning @@ -223,7 +238,9 @@ linters: # The minimum length of a variable's name that is considered "long". # Variable names that are at least this long will be ignored. # Default: 3 - min-name-length: 2 + # Set to 1: single-letter loop indices (i, k) and short-scope helpers + # (a, b for two-node tests; c for clients; m for metrics) are idiomatic. + min-name-length: 1 # Check method receivers. # Default: false check-receiver: false diff --git a/Makefile b/Makefile index ddc5e5e..ab363d2 100644 --- a/Makefile +++ b/Makefile @@ -17,12 +17,28 @@ test: RUN_INTEGRATION_TEST=yes go test -v -timeout 5m -cover ./... test-race: - go test -race ./... + RUN_INTEGRATION_TEST=yes go test -race -count=10 -shuffle=on -timeout=15m ./... + +typecheck: + @echo "Running go vet..." + go vet ./... + +build: + @echo "Building..." + go build -v ./... + +# ci aggregates the gates required before declaring a task done (see AGENTS.md). +ci: lint typecheck test-race sec build + @echo "All CI gates passed." # bench runs the benchmark tests in the benchmark subpackage of the tests package. bench: cd tests/benchmark && go test -bench=. -benchmem -benchtime=4s . -timeout 30m +# bench-baseline captures the current benchmark output to bench-baseline.txt for benchstat comparison. +bench-baseline: + cd tests/benchmark && go test -bench=. -benchmem -benchtime=4s -count=5 . -timeout 30m | tee ../../bench-baseline.txt + # run-example runs the example specified in the example variable with the optional arguments specified in the ARGS variable. run-example: go run ./__examples/$(group)/*.go $(ARGS) @@ -156,9 +172,15 @@ help: @echo @echo "Testing commands:" @echo " test\t\t\t\tRun all tests in the project" + @echo " test-race\t\t\tRun tests with -race -count=10 -shuffle=on" + @echo " bench\t\t\t\tRun benchmarks in tests/benchmark" + @echo " bench-baseline\t\tCapture benchmark baseline to bench-baseline.txt" @echo @echo "Code quality commands:" + @echo " ci\t\t\t\tRun the full quality gate (lint typecheck test-race sec build)" @echo " lint\t\t\t\tRun all linters (gci, gofumpt, staticcheck, golangci-lint)" + @echo " typecheck\t\t\tRun go vet" + @echo " build\t\t\t\tRun go build ./..." @echo " vet\t\t\t\tRun go vet and shadow analysis" @echo " sec\t\t\t\tRun security analysis (govulncheck, gosec)" @echo @@ -167,4 +189,4 @@ help: @echo @echo "For more information, see the project README." -.PHONY: init prepare-toolchain prepare-base-tools update-toolchain test bench vet update-deps lint sec help +.PHONY: init prepare-toolchain prepare-base-tools update-toolchain test test-race typecheck build ci bench bench-baseline vet update-deps lint sec help diff --git a/README.md b/README.md index a38dd9b..47be40c 100644 --- a/README.md +++ b/README.md @@ -368,4 +368,3 @@ I'm a surfer, and a software architect with 15 years of experience designing hig [build-link]: https://github.com/hyp3rd/hypercache/actions/workflows/go.yml [codeql-link]:https://github.com/hyp3rd/hypercache/actions/workflows/codeql.yml -[golangci-lint-link]:https://github.com/hyp3rd/hypercache/actions/workflows/golangci-lint.yml diff --git a/bench-baseline.txt b/bench-baseline.txt new file mode 100644 index 0000000000000000000000000000000000000000..878e8d549a749f3a8eb8638598472c47f8ebb03f GIT binary patch literal 5149 zcmcJT%Wm5+5JkI{zk)wdh4UntU0W3GqD2Gb0|M6(Vj~Y3vith`(r87A3Sz;L?2Qm7 z&cO_4?!EL~UGFZlQ^(&3Oxm*e=P>OLJE6#)o}ENyirXlb)< z!r<_5WLCy_1fIcnJSjPA=iEGY=p?Oz2~^4%>tipL2-EQ$cyQMGTp+7y_e|i@Vt-y< z(a&$qOZrk6iPK`eZO_l^U>hjX`_jVW<8lnA7M$~GT)fAU`_+-PbK*oD$ARgp!+~&y zTsS9ICxM;zaL$*|uMR`L>r-*!ERajpu@0-tg>ww|b@RKreaVLkL$WiC9vDz}RzZ`FAwFy7wCWab`Y$9GKqc+n z0hhVZsm+Y8@I0urv8CYX=Nv+EAs`r>9s2C-U|dy>;7G}NkR)Pf$HiufPl4rt%bPiG zbq*t;Qxzc0RR!k735t539Pfb3(%&m^H!=4vSNqM+>f_H%eAZ9xX%@sRB(`^Ud{&$y zXcU%K#DHvR`w~5KbrFYzY43oj)HC9u*{OqGGh;eBBTgua3ij4~Vl%&Nb}!3H92GA< z*p!W85}KweV>k#1U2=zb>&hMB#JwBC#akZO-lzZV(Xb(4W@5XTCnLEFm zEYUI1wi?pfPWLQ~XBRJd$zG$N4K8_-8_-yI7{ihEfzxCi{NA! zYc-FxQ*lvNpmTF)B^NQ2{EjdgXUR&EXlLVI4DP95F;(OTL*%f8_W$u8^2j;13QRhUNAc>-ran=aZxwVT3L+G7`isv cETJ5$?CRb1^`gEvra0wmyaH8A0JmHG1w9fOegFUf literal 0 HcmV?d00001 diff --git a/pkg/backend/dist_memory.go b/pkg/backend/dist_memory.go index 4f37dad..7fe9d6a 100644 --- a/pkg/backend/dist_memory.go +++ b/pkg/backend/dist_memory.go @@ -821,7 +821,7 @@ func (dm *DistMemory) Remove(ctx context.Context, keys ...string) error { //noli dm.metrics.forwardRemove.Add(1) - _ = dm.transport.ForwardRemove(ctx, string(owners[0]), key, true) //nolint:errcheck // best-effort + _ = dm.transport.ForwardRemove(ctx, string(owners[0]), key, true) } return nil @@ -1662,7 +1662,7 @@ func (dm *DistMemory) sendReplicaDiff( continue } - _ = dm.transport.ForwardSet(ctx, string(rid), it, false) //nolint:errcheck + _ = dm.transport.ForwardSet(ctx, string(rid), it, false) dm.metrics.replicaFanoutSet.Add(1) dm.metrics.rebalancedKeys.Add(1) dm.metrics.rebalanceReplicaDiff.Add(1) @@ -1765,7 +1765,7 @@ func (dm *DistMemory) migrateIfNeeded(ctx context.Context, item *cache.Item) { / dm.metrics.rebalancedKeys.Add(1) dm.metrics.rebalancedPrimary.Add(1) - _ = dm.transport.ForwardSet(ctx, string(owners[0]), item, true) //nolint:errcheck // best-effort + _ = dm.transport.ForwardSet(ctx, string(owners[0]), item, true) // Update originalPrimary so we don't recount repeatedly. sh := dm.shardFor(item.Key) @@ -2117,7 +2117,7 @@ func (dm *DistMemory) repairStaleOwners( } if !ok || it.Version < chosen.Version || (it.Version == chosen.Version && it.Origin > chosen.Origin) { - _ = dm.transport.ForwardSet(ctx, string(oid), chosen, false) //nolint:errcheck + _ = dm.transport.ForwardSet(ctx, string(oid), chosen, false) dm.metrics.readRepair.Add(1) } } @@ -2676,9 +2676,9 @@ func (dm *DistMemory) repairRemoteReplica( return } - it, ok, _ := dm.transport.ForwardGet(ctx, string(oid), key) //nolint:errcheck + it, ok, _ := dm.transport.ForwardGet(ctx, string(oid), key) if !ok || it.Version < chosen.Version || (it.Version == chosen.Version && it.Origin > chosen.Origin) { // stale - _ = dm.transport.ForwardSet(ctx, string(oid), chosen, false) //nolint:errcheck + _ = dm.transport.ForwardSet(ctx, string(oid), chosen, false) dm.metrics.readRepair.Add(1) } } @@ -2817,7 +2817,7 @@ func (dm *DistMemory) applySet(ctx context.Context, item *cache.Item, replicate continue } - _ = dm.transport.ForwardSet(ctx, string(oid), item, false) //nolint:errcheck + _ = dm.transport.ForwardSet(ctx, string(oid), item, false) } } @@ -2901,7 +2901,7 @@ func (dm *DistMemory) applyRemove(ctx context.Context, key string, replicate boo continue } - _ = dm.transport.ForwardRemove(ctx, string(oid), key, false) //nolint:errcheck // best-effort (tombstone inferred remotely) + _ = dm.transport.ForwardRemove(ctx, string(oid), key, false) } } diff --git a/pkg/cache/v2/cmap_bench_test.go b/pkg/cache/v2/cmap_bench_test.go new file mode 100644 index 0000000..758dd79 --- /dev/null +++ b/pkg/cache/v2/cmap_bench_test.go @@ -0,0 +1,99 @@ +package v2 + +import ( + "strconv" + "sync" + "testing" +) + +// BenchmarkConcurrentMap_Count is the regression yardstick for Phase 2a +// (per-shard atomic counter). Today Count() acquires 32 shard RLocks +// sequentially; after Phase 2a it should sum 32 atomics with no locks. +func BenchmarkConcurrentMap_Count(b *testing.B) { + cm := New() + for i := range 4096 { + cm.Set("k"+strconv.Itoa(i), &Item{Key: "k" + strconv.Itoa(i)}) + } + + b.ResetTimer() + + for b.Loop() { + _ = cm.Count() + } +} + +// BenchmarkConcurrentMap_CountParallel exposes the Count() lock storm +// when called concurrently with writers — the realistic eviction-loop scenario. +func BenchmarkConcurrentMap_CountParallel(b *testing.B) { + cm := New() + for i := range 4096 { + cm.Set("k"+strconv.Itoa(i), &Item{Key: "k" + strconv.Itoa(i)}) + } + + stop := make(chan struct{}) + + var wg sync.WaitGroup + + wg.Go(func() { + i := 0 + for { + select { + case <-stop: + return + default: + cm.Set("w"+strconv.Itoa(i), &Item{Key: "w" + strconv.Itoa(i)}) + + i++ + } + } + }) + + b.ResetTimer() + + for b.Loop() { + _ = cm.Count() + } + + b.StopTimer() + close(stop) + wg.Wait() +} + +// BenchmarkConcurrentMap_GetShard measures the cost of the in-process shard hash. +// Phase 2c switches from inlined FNV-1a to xxhash.Sum64String for one canonical hash. +func BenchmarkConcurrentMap_GetShard(b *testing.B) { + cm := New() + keys := make([]string, 1024) + + for i := range keys { + keys[i] = "some-cache-key-" + strconv.Itoa(i) + } + + b.ResetTimer() + + var i int + + for b.Loop() { + _ = cm.GetShard(keys[i&1023]) + i++ + } +} + +// BenchmarkConcurrentMap_IterBuffered measures allocation pressure of the +// channel-based iterator. Phase 2b replaces it with iter.Seq2 — this benchmark +// proves the alloc/op drops to ~0. +func BenchmarkConcurrentMap_IterBuffered(b *testing.B) { + cm := New() + for i := range 4096 { + cm.Set("k"+strconv.Itoa(i), &Item{Key: "k" + strconv.Itoa(i)}) + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + for tup := range cm.IterBuffered() { + _ = tup + } + } +} diff --git a/pkg/cache/v2/cmap_test.go b/pkg/cache/v2/cmap_test.go index 7d88824..a32683f 100644 --- a/pkg/cache/v2/cmap_test.go +++ b/pkg/cache/v2/cmap_test.go @@ -284,6 +284,7 @@ func TestSnapshotPanic(t *testing.T) { }() var cm ConcurrentMap + snapshot(&cm) } diff --git a/pkg/eviction/arc_test.go b/pkg/eviction/arc_test.go index db93a0c..cfacef7 100644 --- a/pkg/eviction/arc_test.go +++ b/pkg/eviction/arc_test.go @@ -17,6 +17,7 @@ func TestARC_BasicSetGetAndEvict(t *testing.T) { // Insert c, causing an eviction arc.Set("c", 3) + // One of a/b should be evicted; the other should remain. if _, ok := arc.Get("a"); !ok { if _, ok2 := arc.Get("b"); !ok2 { diff --git a/pkg/eviction/cawolfu_test.go b/pkg/eviction/cawolfu_test.go index 712b20a..a4e723c 100644 --- a/pkg/eviction/cawolfu_test.go +++ b/pkg/eviction/cawolfu_test.go @@ -10,6 +10,7 @@ func TestCAWOLFU_EvictsLeastFrequentTail(t *testing.T) { c.Set("a", 1) c.Set("b", 2) + // bump 'a' so 'b' is less frequent if _, ok := c.Get("a"); !ok { t.Fatalf("expected to get 'a'") @@ -39,6 +40,7 @@ func TestCAWOLFU_EvictMethodOrder(t *testing.T) { c.Set("a", 1) c.Set("b", 2) + // Without additional access, tail is 'a' (inserted first with same count) key, ok := c.Evict() if !ok || key != "a" { diff --git a/pkg/eviction/clock_test.go b/pkg/eviction/clock_test.go index 08d49d6..bb93ce4 100644 --- a/pkg/eviction/clock_test.go +++ b/pkg/eviction/clock_test.go @@ -22,6 +22,7 @@ func TestClock_EvictsWhenHandFindsColdPage(t *testing.T) { key string ok bool ) + for range 3 { key, ok = clk.Evict() if ok && key == "b" { diff --git a/pkg/stats/histogramcollector_bench_test.go b/pkg/stats/histogramcollector_bench_test.go new file mode 100644 index 0000000..f89bd19 --- /dev/null +++ b/pkg/stats/histogramcollector_bench_test.go @@ -0,0 +1,67 @@ +package stats + +import ( + "testing" + + "github.com/hyp3rd/hypercache/internal/constants" +) + +// BenchmarkHistogramIncr is the single-goroutine baseline for Phase 1a. +// Today this serializes on a global Mutex and appends to an unbounded slice. +func BenchmarkHistogramIncr(b *testing.B) { + c := NewHistogramStatsCollector() + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + c.Incr(constants.StatIncr, 1) + } +} + +// BenchmarkHistogramIncrParallel is the contention regression yardstick. +// Phase 1a should turn this into a lock-free atomic add; expect â‰Ĩ10x. +func BenchmarkHistogramIncrParallel(b *testing.B) { + c := NewHistogramStatsCollector() + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.Incr(constants.StatIncr, 1) + } + }) +} + +// BenchmarkHistogramTimingParallel exercises the same hot path with a different stat key. +func BenchmarkHistogramTimingParallel(b *testing.B) { + c := NewHistogramStatsCollector() + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + c.Timing(constants.StatTiming, 1) + } + }) +} + +// BenchmarkHistogramGetStats measures the read path. Currently sorts the +// backing slice in-place under RLock (race) and re-sorts per-stat multiple times. +func BenchmarkHistogramGetStats(b *testing.B) { + c := NewHistogramStatsCollector() + for range 4096 { + c.Incr(constants.StatIncr, 1) + c.Timing(constants.StatTiming, 5) + c.Gauge(constants.StatGauge, 7) + } + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + _ = c.GetStats() + } +} diff --git a/pool_test.go b/pool_test.go index 4d6346f..77be8f6 100644 --- a/pool_test.go +++ b/pool_test.go @@ -51,6 +51,7 @@ func TestWorkerPool_JobErrorHandling(t *testing.T) { }() var gotErr error + for err := range pool.Errors() { if errors.Is(err, expectedErr) { gotErr = err diff --git a/tests/benchmark/hypercache_concurrency_benchmark_test.go b/tests/benchmark/hypercache_concurrency_benchmark_test.go new file mode 100644 index 0000000..6faa55f --- /dev/null +++ b/tests/benchmark/hypercache_concurrency_benchmark_test.go @@ -0,0 +1,133 @@ +package tests + +import ( + "context" + "strconv" + "sync/atomic" + "testing" + "time" + + "github.com/hyp3rd/hypercache" + "github.com/hyp3rd/hypercache/internal/constants" + "github.com/hyp3rd/hypercache/pkg/backend" +) + +// BenchmarkHyperCache_SetParallel exercises Set under contention. +// Phase 1b removes the redundant outer mutex around eviction-algo updates; +// this benchmark is the regression yardstick for that win. +func BenchmarkHyperCache_SetParallel(b *testing.B) { + cache, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 1_000_000) + if err != nil { + b.Fatal(err) + } + + b.Cleanup(func() { _ = cache.Stop(context.TODO()) }) + + var counter atomic.Uint64 + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + ctx := context.Background() + + for pb.Next() { + i := counter.Add(1) + + _ = cache.Set(ctx, "k"+strconv.FormatUint(i, 10), "v", time.Hour) + } + }) +} + +// BenchmarkHyperCache_GetParallel measures read-side throughput under contention. +// Useful as a control benchmark — Get does not take the eviction-algo mutex. +func BenchmarkHyperCache_GetParallel(b *testing.B) { + cache, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 1_000_000) + if err != nil { + b.Fatal(err) + } + + b.Cleanup(func() { _ = cache.Stop(context.TODO()) }) + + const preload = 1024 + + for i := range preload { + _ = cache.Set(context.TODO(), "k"+strconv.Itoa(i), "v", time.Hour) + } + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + ctx := context.Background() + + var i int + + for pb.Next() { + i++ + + cache.Get(ctx, "k"+strconv.Itoa(i%preload)) + } + }) +} + +// BenchmarkHyperCache_GetOrSetParallel exercises the path that Phase 1c rewrites +// from a goroutine-spawning fire-and-forget to a synchronous update. +func BenchmarkHyperCache_GetOrSetParallel(b *testing.B) { + cache, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 1_000_000) + if err != nil { + b.Fatal(err) + } + + b.Cleanup(func() { _ = cache.Stop(context.TODO()) }) + + var counter atomic.Uint64 + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + ctx := context.Background() + + for pb.Next() { + i := counter.Add(1) + + _, _ = cache.GetOrSet(ctx, "k"+strconv.FormatUint(i, 10), "v", time.Hour) + } + }) +} + +// BenchmarkHyperCache_MixedParallel simulates an 80% read / 20% write workload. +func BenchmarkHyperCache_MixedParallel(b *testing.B) { + config := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + + config.HyperCacheOptions = []hypercache.Option[backend.InMemory]{ + hypercache.WithEvictionAlgorithm[backend.InMemory]("lru"), + } + config.InMemoryOptions = []backend.Option[backend.InMemory]{ + backend.WithCapacity[backend.InMemory](1_000_000), + } + + cache, err := hypercache.New(context.TODO(), hypercache.GetDefaultManager(), config) + if err != nil { + b.Fatal(err) + } + + b.Cleanup(func() { _ = cache.Stop(context.TODO()) }) + + const preload = 4096 + + for i := range preload { + _ = cache.Set(context.TODO(), "k"+strconv.Itoa(i), "v", time.Hour) + } + + var counter atomic.Uint64 + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + ctx := context.Background() + + for pb.Next() { + i := counter.Add(1) + if i%5 == 0 { + _ = cache.Set(ctx, "k"+strconv.FormatUint(i, 10), "v", time.Hour) + } else { + cache.Get(ctx, "k"+strconv.FormatUint(i%preload, 10)) + } + } + }) +} diff --git a/tests/benchmark/hypercache_get_benchmark_test.go b/tests/benchmark/hypercache_get_benchmark_test.go index 5e7be8c..b92c0ad 100644 --- a/tests/benchmark/hypercache_get_benchmark_test.go +++ b/tests/benchmark/hypercache_get_benchmark_test.go @@ -15,7 +15,7 @@ func BenchmarkHyperCache_Get(b *testing.B) { cache, _ := hypercache.NewInMemoryWithDefaults(context.TODO(), 1000) // Store a value in the cache with a key and expiration duration - cache.Set(context.TODO(), "key", "value", time.Hour) + _ = cache.Set(context.TODO(), "key", "value", time.Hour) for b.Loop() { // Retrieve the value from the cache using the key @@ -40,7 +40,7 @@ func BenchmarkHyperCache_Get_ProactiveEviction(b *testing.B) { cache, _ := hypercache.New(context.TODO(), hypercache.GetDefaultManager(), config) // Store a value in the cache with a key and expiration duration - cache.Set(context.TODO(), "key", "value", time.Hour) + _ = cache.Set(context.TODO(), "key", "value", time.Hour) for b.Loop() { // Retrieve the value from the cache using the key diff --git a/tests/benchmark/hypercache_list_benchmark_test.go b/tests/benchmark/hypercache_list_benchmark_test.go index 55fab7c..95fe086 100644 --- a/tests/benchmark/hypercache_list_benchmark_test.go +++ b/tests/benchmark/hypercache_list_benchmark_test.go @@ -14,7 +14,7 @@ func BenchmarkHyperCache_List(b *testing.B) { for b.Loop() { // Store a value in the cache with a key and expiration duration - cache.Set(context.TODO(), "key", "value", time.Hour) + _ = cache.Set(context.TODO(), "key", "value", time.Hour) } list, _ := cache.List(context.TODO()) diff --git a/tests/benchmark/hypercache_set_benchmark_test.go b/tests/benchmark/hypercache_set_benchmark_test.go index 7990251..d141806 100644 --- a/tests/benchmark/hypercache_set_benchmark_test.go +++ b/tests/benchmark/hypercache_set_benchmark_test.go @@ -19,7 +19,7 @@ func BenchmarkHyperCache_Set(b *testing.B) { for i := range b.N { // Store a value in the cache with a key and expiration duration - cache.Set(context.TODO(), fmt.Sprintf("key-%d", i), "value", time.Hour) + _ = cache.Set(context.TODO(), fmt.Sprintf("key-%d", i), "value", time.Hour) } } @@ -41,6 +41,6 @@ func BenchmarkHyperCache_Set_Proactive_Eviction(b *testing.B) { for i := 0; b.Loop(); i++ { // Store a value in the cache with a key and expiration duration - cache.Set(context.TODO(), fmt.Sprintf("key-%d", i), "value", time.Hour) + _ = cache.Set(context.TODO(), fmt.Sprintf("key-%d", i), "value", time.Hour) } } diff --git a/tests/hypercache_distmemory_hint_caps_test.go b/tests/hypercache_distmemory_hint_caps_test.go index a2cfd09..e506c88 100644 --- a/tests/hypercache_distmemory_hint_caps_test.go +++ b/tests/hypercache_distmemory_hint_caps_test.go @@ -42,6 +42,7 @@ func TestHintGlobalCaps(t *testing.T) { //nolint:paralleltest b2 := b2i.(*backend.DistMemory) transport.Register(b1) + // do not register b2 (simulate down replica so hints queue) // Generate many keys to force surpassing global cap (3) quickly. diff --git a/tests/hypercache_distmemory_integration_test.go b/tests/hypercache_distmemory_integration_test.go index 502373a..e2bf467 100644 --- a/tests/hypercache_distmemory_integration_test.go +++ b/tests/hypercache_distmemory_integration_test.go @@ -64,6 +64,7 @@ func TestDistMemoryForwardingReplication(t *testing.T) { target := owners[0] var err2 error + switch target { case n1.ID: err2 = b1.Set(context.Background(), item) diff --git a/tests/hypercache_distmemory_remove_readrepair_test.go b/tests/hypercache_distmemory_remove_readrepair_test.go index 60bdce5..455bd18 100644 --- a/tests/hypercache_distmemory_remove_readrepair_test.go +++ b/tests/hypercache_distmemory_remove_readrepair_test.go @@ -182,6 +182,7 @@ func TestDistMemoryReadRepair(t *testing.T) { // metrics should show at least one read repair var repaired bool + if replica == b1.LocalNodeID() { repaired = b1.Metrics().ReadRepair > 0 } else { diff --git a/tests/hypercache_http_merkle_test.go b/tests/hypercache_http_merkle_test.go index 93cca06..17e5f95 100644 --- a/tests/hypercache_http_merkle_test.go +++ b/tests/hypercache_http_merkle_test.go @@ -19,23 +19,26 @@ func TestHTTPFetchMerkle(t *testing.T) { ring := cluster.NewRing(cluster.WithReplication(1)) membership := cluster.NewMembership(ring) - // create two nodes with HTTP server enabled (addresses) - n1 := cluster.NewNode("", "127.0.0.1:9201") + // create two nodes with HTTP server enabled (dynamically allocated addresses) + addr1 := AllocatePort(t) + addr2 := AllocatePort(t) + + n1 := cluster.NewNode("", addr1) b1i, err := backend.NewDistMemory(ctx, backend.WithDistMembership(membership, n1), - backend.WithDistNode("n1", "127.0.0.1:9201"), + backend.WithDistNode("n1", addr1), backend.WithDistMerkleChunkSize(2), ) if err != nil { t.Fatalf("b1: %v", err) } - n2 := cluster.NewNode("", "127.0.0.1:9202") + n2 := cluster.NewNode("", addr2) b2i, err := backend.NewDistMemory(ctx, backend.WithDistMembership(membership, n2), - backend.WithDistNode("n2", "127.0.0.1:9202"), + backend.WithDistNode("n2", addr2), backend.WithDistMerkleChunkSize(2), ) if err != nil { diff --git a/tests/hypercache_mgmt_dist_test.go b/tests/hypercache_mgmt_dist_test.go index 8f76800..5739013 100644 --- a/tests/hypercache_mgmt_dist_test.go +++ b/tests/hypercache_mgmt_dist_test.go @@ -101,6 +101,7 @@ func waitForMgmt(t *testing.T, hc *hypercache.HyperCache[backend.DistMemory]) st deadline := time.Now().Add(2 * time.Second) var addr string + for time.Now().Before(deadline) { addr = hc.ManagementHTTPAddress() if addr != "" { diff --git a/tests/integration/dist_rebalance_leave_test.go b/tests/integration/dist_rebalance_leave_test.go index 3f4cf23..40e14b9 100644 --- a/tests/integration/dist_rebalance_leave_test.go +++ b/tests/integration/dist_rebalance_leave_test.go @@ -52,15 +52,10 @@ func TestDistRebalanceLeave(t *testing.T) { // Allow multiple rebalance ticks. time.Sleep(1200 * time.Millisecond) - // After removal, C should not be primary for any sampled key and ownership redistributed to A/B. - sample := sampleKeys(totalKeys) - - ownedC := ownedPrimaryCount(nodeC, sample) - if ownedC != 0 { - // Ring on C still includes itself; test focuses on redistribution observed from surviving nodes. - // So we only assert A and B now have some keys formerly held by C via migration metrics. - // Continue without failing here; main assertion below. - } + // We deliberately do not assert on ownedPrimaryCount(nodeC, ...) here: + // node C still has itself in its own ring view, so it may still report + // itself as primary for sampled keys. The redistribution we care about + // is observed via migration metrics on surviving nodes, asserted below. // Migration metrics on surviving nodes should have increased (some keys moved off departed node C). migrated := nodeA.Metrics().RebalancedKeys + nodeB.Metrics().RebalancedKeys diff --git a/tests/merkle_delete_tombstone_test.go b/tests/merkle_delete_tombstone_test.go index 5fad41f..4a18015 100644 --- a/tests/merkle_delete_tombstone_test.go +++ b/tests/merkle_delete_tombstone_test.go @@ -16,13 +16,13 @@ func TestMerkleDeleteTombstone(t *testing.T) { a, _ := backend.NewDistMemory( ctx, - backend.WithDistNode("A", "127.0.0.1:9501"), + backend.WithDistNode("A", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(2), ) b, _ := backend.NewDistMemory( ctx, - backend.WithDistNode("B", "127.0.0.1:9502"), + backend.WithDistNode("B", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(2), ) diff --git a/tests/merkle_empty_tree_test.go b/tests/merkle_empty_tree_test.go index e4b2c68..d8efa96 100644 --- a/tests/merkle_empty_tree_test.go +++ b/tests/merkle_empty_tree_test.go @@ -14,13 +14,13 @@ func TestMerkleEmptyTrees(t *testing.T) { a, _ := backend.NewDistMemory( ctx, - backend.WithDistNode("A", "127.0.0.1:9201"), + backend.WithDistNode("A", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(2), ) b, _ := backend.NewDistMemory( ctx, - backend.WithDistNode("B", "127.0.0.1:9202"), + backend.WithDistNode("B", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(2), ) diff --git a/tests/merkle_no_diff_test.go b/tests/merkle_no_diff_test.go index f628f74..207c414 100644 --- a/tests/merkle_no_diff_test.go +++ b/tests/merkle_no_diff_test.go @@ -16,13 +16,13 @@ func TestMerkleNoDiff(t *testing.T) { a, _ := backend.NewDistMemory( ctx, - backend.WithDistNode("A", "127.0.0.1:9401"), + backend.WithDistNode("A", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(4), ) b, _ := backend.NewDistMemory( ctx, - backend.WithDistNode("B", "127.0.0.1:9402"), + backend.WithDistNode("B", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(4), ) diff --git a/tests/merkle_single_missing_key_test.go b/tests/merkle_single_missing_key_test.go index 67538f5..485c956 100644 --- a/tests/merkle_single_missing_key_test.go +++ b/tests/merkle_single_missing_key_test.go @@ -16,13 +16,13 @@ func TestMerkleSingleMissingKey(t *testing.T) { a, _ := backend.NewDistMemory( ctx, - backend.WithDistNode("A", "127.0.0.1:9301"), + backend.WithDistNode("A", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(2), ) b, _ := backend.NewDistMemory( ctx, - backend.WithDistNode("B", "127.0.0.1:9302"), + backend.WithDistNode("B", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(2), ) diff --git a/tests/merkle_sync_test.go b/tests/merkle_sync_test.go index 71020d1..208d25c 100644 --- a/tests/merkle_sync_test.go +++ b/tests/merkle_sync_test.go @@ -15,7 +15,7 @@ func TestMerkleSyncConvergence(t *testing.T) { transport := backend.NewInProcessTransport() bA, err := backend.NewDistMemory(ctx, - backend.WithDistNode("A", "127.0.0.1:9101"), + backend.WithDistNode("A", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(2), ) @@ -26,7 +26,7 @@ func TestMerkleSyncConvergence(t *testing.T) { dmA := any(bA).(*backend.DistMemory) bB, err := backend.NewDistMemory(ctx, - backend.WithDistNode("B", "127.0.0.1:9102"), + backend.WithDistNode("B", AllocatePort(t)), backend.WithDistReplication(1), backend.WithDistMerkleChunkSize(2), ) diff --git a/tests/port_helper.go b/tests/port_helper.go new file mode 100644 index 0000000..04cc4b8 --- /dev/null +++ b/tests/port_helper.go @@ -0,0 +1,36 @@ +package tests + +import ( + "context" + "net" + "testing" +) + +// AllocatePort returns a free TCP loopback address ("127.0.0.1:N") for tests +// that need to bind a server. Listening on :0 lets the kernel pick an unused +// port; we close immediately and return the address. Two tests calling this +// concurrently can in theory collide on the same port if the kernel reissues +// it before either test binds, but in practice the window is too short to +// matter for our serial-package test runs. +// +// Use this instead of hard-coded ports so that -shuffle and -count=N do not +// induce flake from port reuse across tests in the same process. +func AllocatePort(tb testing.TB) string { + tb.Helper() + + var lc net.ListenConfig + + listener, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0") + if err != nil { + tb.Fatalf("allocate port: %v", err) + } + + addr := listener.Addr().String() + + closeErr := listener.Close() + if closeErr != nil { + tb.Fatalf("close port listener: %v", closeErr) + } + + return addr +} From 179e36419313e0c8cfc3fe8daec8b58af60d081a Mon Sep 17 00:00:00 2001 From: "F." <62474964+hyp3rd@users.noreply.github.com> Date: Sun, 3 May 2026 01:49:31 +0200 Subject: [PATCH 4/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 520c3e2..d6a3917 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ jobs: source .project-settings.env set +a echo "go_version=${GO_VERSION}" >> "$GITHUB_OUTPUT" - echo "gci_prefix=${GCI_PREFIX:-github.com/hyp3rd/ewrap}" >> "$GITHUB_OUTPUT" + echo "gci_prefix=${GCI_PREFIX:-github.com/hyp3rd/hypercache}" >> "$GITHUB_OUTPUT" echo "golangci_lint_version=${GOLANGCI_LINT_VERSION}" >> "$GITHUB_OUTPUT" echo "proto_enabled=${PROTO_ENABLED:-true}" >> "$GITHUB_OUTPUT" - name: Setup Go From ae54dc712b04bab7cc59c6c1c91d5bb6bb04e9f2 Mon Sep 17 00:00:00 2001 From: "F." Date: Sun, 3 May 2026 12:41:56 +0200 Subject: [PATCH 5/8] refactor!: eliminate redundant mutexes and fix data races across core packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the global sync.RWMutex from HyperCache and the InMemory backend, delegating thread-safety to the eviction algorithm's internal lock and the sharded ConcurrentMap respectively. Rewrite HistogramStatsCollector with per-stat atomic aggregates (count, sum, min, max) and a bounded ring buffer (DefaultSampleCapacity=4096) to fix the sort-under-RLock data race where GetStats sorted the live backing slice in-place while concurrent writers appended to it. Store the DistMemory transport in an atomic.Pointer via loadTransport/storeTransport helpers to prevent concurrent read/write races across forwarding, replication, and hinted-handoff paths. Additional fixes: - NewConfig now returns (*Config[T], error) instead of panicking on an empty backendType; all call-sites updated including examples and benchmarks - DistMemory.Stop is now idempotent and closes tombStopCh and rebalanceStopCh goroutines that were previously leaked - WorkerPool.Enqueue silently drops jobs after Shutdown instead of sending on a closed channel, preventing panics during graceful cache teardown - Suppress Fiber startup banner in management and dist HTTP servers to reduce -count=N test noise - Fix typo ErrMegMissingCacheKey → ErrMsgMissingCacheKey - Harden distributed tests with StopOnCleanup, polling loops instead of fixed sleeps, and increased heartbeat/merkle timeouts for -race runs BREAKING CHANGE: NewConfig signature changed from *Config[T] to (*Config[T], error). --- .golangci.yaml | 11 +- __examples/eviction/eviction.go | 8 +- __examples/size/size.go | 7 +- __examples/stats/stats.go | 8 +- bench-phase1.txt | 51 +++ config.go | 16 +- hypercache.go | 63 ++-- internal/constants/errors.go | 4 +- management_http.go | 6 +- pkg/backend/dist_http_server.go | 13 +- pkg/backend/dist_memory.go | 190 +++++++--- pkg/backend/inmemory.go | 14 +- pkg/cache/v2/cmap_test.go | 9 +- pkg/stats/histogramcollector.go | 329 ++++++++++++----- pkg/stats/histogramcollector_bench_test.go | 28 ++ pkg/stats/histogramcollector_test.go | 346 ++++++++++++++++++ pool.go | 37 +- pool_test.go | 22 +- .../hypercache_concurrency_benchmark_test.go | 5 +- .../hypercache_get_benchmark_test.go | 5 +- .../hypercache_set_benchmark_test.go | 5 +- .../hypercache_dist_benchmark_test.go | 36 +- ...rcache_distmemory_failure_recovery_test.go | 14 +- ...ache_distmemory_heartbeat_sampling_test.go | 21 +- tests/hypercache_distmemory_heartbeat_test.go | 39 +- tests/hypercache_distmemory_hint_caps_test.go | 14 +- ...percache_distmemory_hinted_handoff_test.go | 5 +- .../hypercache_distmemory_integration_test.go | 14 +- ...cache_distmemory_remove_readrepair_test.go | 18 +- ...hypercache_distmemory_stale_quorum_test.go | 21 +- tests/hypercache_distmemory_tiebreak_test.go | 21 +- .../hypercache_distmemory_versioning_test.go | 22 +- ...hypercache_distmemory_write_quorum_test.go | 45 ++- tests/hypercache_get_multiple_test.go | 2 +- tests/hypercache_http_merkle_test.go | 59 ++- tests/hypercache_mgmt_dist_test.go | 13 +- tests/hypercache_trigger_eviction_test.go | 15 +- tests/integration/dist_phase1_test.go | 13 +- .../integration/dist_rebalance_leave_test.go | 8 +- .../dist_rebalance_replica_diff_test.go | 8 +- ...st_rebalance_replica_diff_throttle_test.go | 8 +- tests/integration/dist_rebalance_test.go | 28 +- tests/management_http_test.go | 43 ++- tests/merkle_delete_tombstone_test.go | 16 +- tests/merkle_empty_tree_test.go | 16 +- tests/merkle_no_diff_test.go | 16 +- tests/merkle_single_missing_key_test.go | 16 +- tests/merkle_sync_test.go | 16 +- tests/port_helper.go | 20 + 49 files changed, 1405 insertions(+), 339 deletions(-) create mode 100644 bench-phase1.txt create mode 100644 pkg/stats/histogramcollector_test.go diff --git a/.golangci.yaml b/.golangci.yaml index e8bec40..4ebe1bd 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -14,7 +14,7 @@ run: issues-exit-code: 2 # Include test files or not. # Default: true - tests: false + tests: true # List of build tags, all linters use it. # Default: [] # build-tags: @@ -62,15 +62,12 @@ linters: # testpackage: project consistently uses internal _test packages # (access to unexported helpers without re-exporting). Documented choice. - testpackage - # paralleltest: tracked separately; many tests share a cache instance and - # cannot be parallelized without refactoring (see Phase 1). - - paralleltest settings: cyclop: # The maximal code complexity to report. # Default: 10 - max-complexity: 12 + max-complexity: 15 errcheck: # Report about not checking of errors in type assertions: `a := b.(MyStruct)`. # Such cases aren't reported by default. @@ -168,7 +165,7 @@ linters: disabled: true - name: line-length-limit - arguments: [ 160 ] + arguments: [ 150 ] - name: var-naming disabled: true @@ -360,7 +357,7 @@ formatters: golines: # Target maximum line length. # Default: 100 - max-len: 140 + max-len: 150 # Length of a tabulation. # Default: 4 # tab-len: 8 diff --git a/__examples/eviction/eviction.go b/__examples/eviction/eviction.go index ecfe29c..44d7aa3 100644 --- a/__examples/eviction/eviction.go +++ b/__examples/eviction/eviction.go @@ -32,7 +32,13 @@ func main() { // executeExample runs the example. func executeExample(ctx context.Context, evictionInterval time.Duration) { // Create a new HyperCache with a capacity of 10 - config := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + config, err := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + if err != nil { + fmt.Println(err) + + return + } + config.HyperCacheOptions = []hypercache.Option[backend.InMemory]{ hypercache.WithEvictionInterval[backend.InMemory](evictionInterval), } diff --git a/__examples/size/size.go b/__examples/size/size.go index adb787b..0caf3bb 100644 --- a/__examples/size/size.go +++ b/__examples/size/size.go @@ -54,7 +54,12 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), constants.DefaultTimeout*2) defer cancel() - config := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + config, err := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + if err != nil { + fmt.Fprintln(os.Stderr, err) + + return + } config.HyperCacheOptions = []hypercache.Option[backend.InMemory]{ hypercache.WithEvictionInterval[backend.InMemory](0), diff --git a/__examples/stats/stats.go b/__examples/stats/stats.go index f928f2f..7eb4a15 100644 --- a/__examples/stats/stats.go +++ b/__examples/stats/stats.go @@ -20,7 +20,13 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), constants.DefaultTimeout) defer cancel() // Create a new HyperCache with a capacity of 100 - config := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + config, err := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + if err != nil { + fmt.Fprintln(os.Stderr, err) + + return + } + config.HyperCacheOptions = []hypercache.Option[backend.InMemory]{ hypercache.WithEvictionInterval[backend.InMemory](constants.DefaultEvictionInterval), hypercache.WithEvictionAlgorithm[backend.InMemory](constants.DefaultEvictionAlgorithm), diff --git a/bench-phase1.txt b/bench-phase1.txt new file mode 100644 index 0000000..08c8cb7 --- /dev/null +++ b/bench-phase1.txt @@ -0,0 +1,51 @@ +goos: darwin +goarch: arm64 +pkg: github.com/hyp3rd/hypercache/tests/benchmark +cpu: Apple M4 Pro +BenchmarkHyperCache_SetParallel-14 6209270 716.0 ns/op 223 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6455631 783.7 ns/op 220 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6529954 781.8 ns/op 219 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6415386 787.3 ns/op 221 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6554228 787.0 ns/op 219 B/op 3 allocs/op +BenchmarkHyperCache_GetParallel-14 53781536 89.38 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 53632083 89.92 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 52835577 87.68 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 53531523 87.95 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 53525479 91.84 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6200666 752.3 ns/op 223 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6393361 735.8 ns/op 221 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6340680 786.5 ns/op 222 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6393583 784.1 ns/op 221 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6464352 804.3 ns/op 220 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 30741903 172.8 ns/op 158 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 28517773 175.8 ns/op 160 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 29075665 170.9 ns/op 159 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 29588047 175.9 ns/op 159 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 30058425 162.6 ns/op 159 B/op 3 allocs/op +BenchmarkHyperCache_Get-14 41194540 108.9 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 44170627 109.3 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 44089144 109.7 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 43422474 109.7 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 44358532 108.8 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 43800232 109.6 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 43563344 111.2 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 42146994 111.4 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 40192420 113.1 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 42018354 113.4 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 45661159 103.6 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 47006362 101.6 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 46921814 101.1 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 47563152 100.2 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 46937605 101.1 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Set-14 12518250 450.2 ns/op 224 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 11584551 464.7 ns/op 230 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 12283092 447.8 ns/op 225 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 11601224 507.2 ns/op 230 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 8849468 539.7 ns/op 254 B/op 3 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3087795 1385 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3858516 1181 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3961390 1164 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3892960 1152 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3880062 1172 ns/op 454 B/op 6 allocs/op +PASS +ok github.com/hyp3rd/hypercache/tests/benchmark 260.917s diff --git a/config.go b/config.go index 2e2cba2..42edd07 100644 --- a/config.go +++ b/config.go @@ -4,7 +4,8 @@ // // Example usage: // -// config := hypercache.NewConfig[string]("inmemory") +// config, err := hypercache.NewConfig[string]("inmemory") +// if err != nil { /* ... */ } // cache := hypercache.NewHyperCache[string](config) // cache.Set("key", "value", time.Hour) // value, found := cache.Get("key") @@ -14,7 +15,10 @@ import ( "strings" "time" + "github.com/hyp3rd/ewrap" + "github.com/hyp3rd/hypercache/internal/constants" + "github.com/hyp3rd/hypercache/internal/sentinel" "github.com/hyp3rd/hypercache/pkg/backend" ) @@ -44,9 +48,13 @@ type Config[T backend.IBackendConstrain] struct { // // Each of the above options can be overridden by passing a different option to the `NewConfig` function. // It can be used to configure `HyperCache` and its backend and customize the behavior of the cache. -func NewConfig[T backend.IBackendConstrain](backendType string) *Config[T] { +// +// NewConfig returns sentinel.ErrParamCannotBeEmpty if backendType is empty or +// whitespace-only. Previous versions panicked in that case; v2 returns the +// error so callers can handle invalid configuration without unwinding. +func NewConfig[T backend.IBackendConstrain](backendType string) (*Config[T], error) { if strings.TrimSpace(backendType) == "" { - panic("empty backend type") + return nil, ewrap.Wrap(sentinel.ErrParamCannotBeEmpty, "backendType") } return &Config[T]{ @@ -60,7 +68,7 @@ func NewConfig[T backend.IBackendConstrain](backendType string) *Config[T] { WithEvictionAlgorithm[T]("lfu"), WithEvictionInterval[T](constants.DefaultEvictionInterval), }, - } + }, nil } // Option is a function type that can be used to configure the `HyperCache` struct. diff --git a/hypercache.go b/hypercache.go index a3eeaf7..66b25d9 100644 --- a/hypercache.go +++ b/hypercache.go @@ -61,7 +61,6 @@ type HyperCache[T backend.IBackendConstrain] struct { maxEvictionCount uint // max items per eviction run maxCacheSize int64 // hard memory limit (MB), 0 = unlimited memoryAllocation atomic.Int64 // current memory usage (bytes) - mutex sync.RWMutex // protects eviction algorithm once sync.Once // ensures background loops start once statsCollectorName string // configured stats collector name // StatsCollector to collect cache statistics @@ -83,7 +82,11 @@ type touchBackend interface { // - The maximum cache size in bytes is set to 0 (no limitations). func NewInMemoryWithDefaults(ctx context.Context, capacity int) (*HyperCache[backend.InMemory], error) { // Initialize the configuration - config := NewConfig[backend.InMemory](constants.InMemoryBackend) + config, err := NewConfig[backend.InMemory](constants.InMemoryBackend) + if err != nil { + return nil, err + } + // Set the default options config.HyperCacheOptions = []Option[backend.InMemory]{ WithEvictionInterval[backend.InMemory](constants.DefaultEvictionInterval), @@ -559,12 +562,11 @@ func (hyperCache *HyperCache[T]) evictionLoop(ctx context.Context) { break } - // Protect eviction algorithm access - hyperCache.mutex.Lock() - + // Each eviction algorithm provides its own internal mutex; no + // outer lock needed. The Evict() -> Remove() window below is + // already non-atomic by design (other workers may insert items + // between the two calls); a wider lock would not change that. key, ok := hyperCache.evictionAlgorithm.Evict() - hyperCache.mutex.Unlock() - if !ok { // no more items to evict break @@ -602,11 +604,7 @@ func (hyperCache *HyperCache[T]) evictionLoop(ctx context.Context) { // evictItem is a helper function that removes an item from the cache and returns the key of the evicted item. // If no item can be evicted, it returns a false. func (hyperCache *HyperCache[T]) evictItem(ctx context.Context) (string, bool) { - hyperCache.mutex.Lock() - key, ok := hyperCache.evictionAlgorithm.Evict() - hyperCache.mutex.Unlock() - if !ok { // no more items to evict return "", false @@ -659,10 +657,9 @@ func (hyperCache *HyperCache[T]) Set(ctx context.Context, key string, value any, return err } - // Set the item in the eviction algorithm - hyperCache.mutex.Lock() + // Set the item in the eviction algorithm. The algorithm protects its own + // state internally; no outer lock needed. hyperCache.evictionAlgorithm.Set(key, item.Value) - hyperCache.mutex.Unlock() // If the cache is at capacity, evict an item when the eviction interval is zero if hyperCache.shouldEvict.Load() && hyperCache.backend.Count(ctx) > hyperCache.backend.Capacity() { @@ -772,17 +769,16 @@ func (hyperCache *HyperCache[T]) GetOrSet(ctx context.Context, key string, value return nil, err } - go func() { - // Set the item in the eviction algorithm - hyperCache.mutex.Lock() - hyperCache.evictionAlgorithm.Set(key, item.Value) - hyperCache.mutex.Unlock() - - // If the cache is at capacity, evict an item when the eviction interval is zero - if hyperCache.shouldEvict.Load() && hyperCache.backend.Count(ctx) > hyperCache.backend.Capacity() { - hyperCache.evictItem(ctx) - } - }() + // Set the item in the eviction algorithm synchronously. The previous bare + // goroutine had no panic recovery, no shutdown coordination with Stop(), + // and let the caller observe a key whose eviction tracking had not yet + // been recorded. The algorithm's own internal mutex provides safety. + hyperCache.evictionAlgorithm.Set(key, item.Value) + + // If the cache is at capacity, evict an item when the eviction interval is zero + if hyperCache.shouldEvict.Load() && hyperCache.backend.Count(ctx) > hyperCache.backend.Capacity() { + hyperCache.evictItem(ctx) + } return value, nil } @@ -882,9 +878,7 @@ func (hyperCache *HyperCache[T]) Remove(ctx context.Context, keys ...string) err if ok { // remove the item from the cacheBackend and update the memory allocation hyperCache.memoryAllocation.Add(-item.Size) - hyperCache.mutex.Lock() hyperCache.evictionAlgorithm.Delete(key) - hyperCache.mutex.Unlock() } } @@ -916,9 +910,7 @@ func (hyperCache *HyperCache[T]) Clear(ctx context.Context) error { } for _, item := range items { - hyperCache.mutex.Lock() hyperCache.evictionAlgorithm.Delete(item.Key) - hyperCache.mutex.Unlock() } // reset the memory allocation @@ -1023,16 +1015,11 @@ func (hyperCache *HyperCache[T]) Stop(ctx context.Context) error { return nil } -// GetStats returns the stats collected by the cache. +// GetStats returns the stats collected by the cache. Thread-safety is +// delegated to the configured StatsCollector implementation (the default +// HistogramStatsCollector is fully thread-safe with no global lock). func (hyperCache *HyperCache[T]) GetStats() stats.Stats { - // Lock the cache's mutex to ensure thread-safety - hyperCache.mutex.RLock() - defer hyperCache.mutex.RUnlock() - - // Get the stats from the stats collector - statsOut := hyperCache.StatsCollector.GetStats() - - return statsOut + return hyperCache.StatsCollector.GetStats() } // DistMetrics returns distributed backend metrics if the underlying backend is DistMemory. diff --git a/internal/constants/errors.go b/internal/constants/errors.go index 48c3e90..6437afb 100644 --- a/internal/constants/errors.go +++ b/internal/constants/errors.go @@ -3,8 +3,8 @@ package constants const ( // ErrorLabel is a common label for all errors in HyperCache. ErrorLabel = "error" - // ErrMegMissingCacheKey is returned when cache key is missing in request. - ErrMegMissingCacheKey = "missing cache key" + // ErrMsgMissingCacheKey is returned when cache key is missing in request. + ErrMsgMissingCacheKey = "missing cache key" // ErrMsgUnsupportedDistributedBackend is returned when the distributed backend does not support the requested operation. ErrMsgUnsupportedDistributedBackend = "distributed backend unsupported" ) diff --git a/management_http.go b/management_http.go index 5dc8edd..e2c6927 100644 --- a/management_http.go +++ b/management_http.go @@ -123,7 +123,9 @@ func (s *ManagementHTTPServer) Start(ctx context.Context, hc managementCache) er s.ln = ln go func() { // serve in background (optional server errors are ignored intentionally) - err = s.app.Listener(ln) + // Suppress fiber's startup banner so tests at -count=N do not drown + // real failures under hundreds of "INFO Server started on..." lines. + err = s.app.Listener(ln, fiber.ListenConfig{DisableStartupMessage: true}) if err != nil { // optional server; log hook could be added in future _ = err } @@ -229,7 +231,7 @@ func (s *ManagementHTTPServer) registerDistributed(useAuth func(fiber.Handler) f if dist, ok := hc.(managementCacheDistOpt); ok { key := fiberCtx.Query("key") if key == "" { - return fiberCtx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) + return fiberCtx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgMissingCacheKey}) } owners := dist.ClusterOwners(key) diff --git a/pkg/backend/dist_http_server.go b/pkg/backend/dist_http_server.go index 15b811b..df659fe 100644 --- a/pkg/backend/dist_http_server.go +++ b/pkg/backend/dist_http_server.go @@ -101,7 +101,7 @@ func (s *distHTTPServer) registerGet(_ context.Context, dm *DistMemory) { //noli s.app.Get("/internal/cache/get", func(fctx fiber.Ctx) error { key := fctx.Query("key") if key == "" { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgMissingCacheKey}) } owners := dm.lookupOwners(key) @@ -120,7 +120,7 @@ func (s *distHTTPServer) registerGet(_ context.Context, dm *DistMemory) { //noli s.app.Get("/internal/get", func(fctx fiber.Ctx) error { key := fctx.Query("key") if key == "" { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgMissingCacheKey}) } owners := dm.lookupOwners(key) @@ -141,7 +141,7 @@ func (s *distHTTPServer) registerRemove(ctx context.Context, dm *DistMemory) { / s.app.Delete("/internal/cache/remove", func(fctx fiber.Ctx) error { key := fctx.Query("key") if key == "" { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgMissingCacheKey}) } replicate, parseErr := strconv.ParseBool(fctx.Query("replicate", "false")) @@ -158,7 +158,7 @@ func (s *distHTTPServer) registerRemove(ctx context.Context, dm *DistMemory) { / s.app.Delete("/internal/del", func(fctx fiber.Ctx) error { key := fctx.Query("key") if key == "" { - return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMegMissingCacheKey}) + return fctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{constants.ErrorLabel: constants.ErrMsgMissingCacheKey}) } replicate, parseErr := strconv.ParseBool(fctx.Query("replicate", "false")) @@ -217,7 +217,10 @@ func (s *distHTTPServer) listen(ctx context.Context) error { //nolint:ireturn s.ln = ln go func() { // capture server errors (ignored intentionally for now) - serveErr := s.app.Listener(ln) + // DisableStartupMessage avoids fiber's per-instance banner spam, + // which would otherwise flood test output at -count=N (see hundreds of + // "INFO Server started on..." lines drowning real failures). + serveErr := s.app.Listener(ln, fiber.ListenConfig{DisableStartupMessage: true}) if serveErr != nil { // separated for noinlineerr linter _ = serveErr } diff --git a/pkg/backend/dist_memory.go b/pkg/backend/dist_memory.go index 7fe9d6a..2a85fad 100644 --- a/pkg/backend/dist_memory.go +++ b/pkg/backend/dist_memory.go @@ -46,7 +46,11 @@ type DistMemory struct { localNode *cluster.Node membership *cluster.Membership ring *cluster.Ring - transport DistTransport + // transport holds the active DistTransport behind an atomic.Pointer so that + // callers (including the hint-replay goroutine) can read it without racing + // against SetTransport / WithDistTransport / lazy HTTP server bring-up. + // Use loadTransport() / storeTransport() instead of touching this directly. + transport atomic.Pointer[distTransportSlot] httpServer *distHTTPServer // optional internal HTTP server metrics distMetrics // configuration (static for now, future: dynamic membership/gossip) @@ -127,6 +131,9 @@ type DistMemory struct { // shedding / cleanup of keys we no longer own (grace period before delete to aid late reads / hinted handoff) removalGracePeriod time.Duration // if >0 we delay deleting keys we no longer own; after grace we remove locally + + // stopped guards Stop() against double-invocation (idempotent shutdown). + stopped atomic.Bool } const ( @@ -136,6 +143,10 @@ const ( var errUnexpectedBackendType = errors.New("backend: unexpected backend type") // stable error (no dynamic wrapping needed) +// distTransportSlot wraps a DistTransport interface value so it can be stored +// in atomic.Pointer (atomic.Pointer requires a concrete pointer type). +type distTransportSlot struct{ t DistTransport } + // hintedEntry represents a deferred replica write. type hintedEntry struct { item *cache.Item @@ -383,13 +394,14 @@ func equalBytes(a, b []byte) bool { // tiny helper // SyncWith performs Merkle anti-entropy against a remote node (pull newer versions for differing chunks). func (dm *DistMemory) SyncWith(ctx context.Context, nodeID string) error { //nolint:ireturn - if dm.transport == nil { + transport := dm.loadTransport() + if transport == nil { return errNoTransport } startFetch := time.Now() - remoteTree, err := dm.transport.FetchMerkle(ctx, nodeID) + remoteTree, err := transport.FetchMerkle(ctx, nodeID) if err != nil { return err } @@ -443,7 +455,7 @@ func WithDistMembership(m *cluster.Membership, node *cluster.Node) DistMemoryOpt // WithDistTransport sets a transport used for forwarding / replication. func WithDistTransport(t DistTransport) DistMemoryOption { - return func(dm *DistMemory) { dm.transport = t } + return func(dm *DistMemory) { dm.storeTransport(t) } } // WithDistHeartbeatSample sets how many random peers to probe per heartbeat tick (0=all). @@ -452,7 +464,7 @@ func WithDistHeartbeatSample(k int) DistMemoryOption { //nolint:ireturn } // SetTransport sets the transport post-construction (testing helper). -func (dm *DistMemory) SetTransport(t DistTransport) { dm.transport = t } +func (dm *DistMemory) SetTransport(t DistTransport) { dm.storeTransport(t) } // WithDistHeartbeat configures heartbeat interval and suspect/dead thresholds. // If interval <= 0 heartbeat is disabled. @@ -810,7 +822,8 @@ func (dm *DistMemory) Remove(ctx context.Context, keys ...string) error { //noli continue } - if dm.transport == nil { // non-owner without transport + transport := dm.loadTransport() + if transport == nil { // non-owner without transport return sentinel.ErrNotOwner } @@ -821,7 +834,7 @@ func (dm *DistMemory) Remove(ctx context.Context, keys ...string) error { //noli dm.metrics.forwardRemove.Add(1) - _ = dm.transport.ForwardRemove(ctx, string(owners[0]), key, true) + _ = transport.ForwardRemove(ctx, string(owners[0]), key, true) } return nil @@ -1100,8 +1113,15 @@ func (dm *DistMemory) LatencyHistograms() map[string][]uint64 { //nolint:ireturn return dm.latency.snapshot() } -// Stop stops heartbeat loop if running. +// Stop terminates every background goroutine started by NewDistMemory and +// shuts down the optional HTTP server. Idempotent and safe to call concurrently +// — repeat calls are no-ops. Tests SHOULD register Stop via t.Cleanup to avoid +// goroutine leaks across `-count=N` iterations under -race. func (dm *DistMemory) Stop(ctx context.Context) error { //nolint:ireturn + if !dm.stopped.CompareAndSwap(false, true) { + return nil + } + if dm.stopCh != nil { close(dm.stopCh) @@ -1126,17 +1146,28 @@ func (dm *DistMemory) Stop(ctx context.Context) error { //nolint:ireturn dm.autoSyncStopCh = nil } + if dm.tombStopCh != nil { // stop tomb sweeper + close(dm.tombStopCh) + + dm.tombStopCh = nil + } + + if dm.rebalanceStopCh != nil { // stop rebalance loop (was leaking pre-fix) + close(dm.rebalanceStopCh) + + dm.rebalanceStopCh = nil + } + if dm.httpServer != nil { err := dm.httpServer.stop(ctx) // best-effort + + dm.httpServer = nil + if err != nil { return err } } - if dm.tombStopCh != nil { // stop tomb sweeper - close(dm.tombStopCh) - } - return nil } @@ -1201,7 +1232,7 @@ func (dm *DistMemory) resolveMissingKeys(ctx context.Context, nodeID string, ent return missing } - httpT, ok := dm.transport.(*DistHTTPTransport) + httpT, ok := dm.loadTransport().(*DistHTTPTransport) if !ok { return missing } @@ -1254,11 +1285,27 @@ func (dm *DistMemory) applyMerkleDiffs( } } +// loadTransport returns the currently configured transport, or nil. Safe to +// call concurrently with storeTransport. +func (dm *DistMemory) loadTransport() DistTransport { + if slot := dm.transport.Load(); slot != nil { + return slot.t + } + + return nil +} + +// storeTransport replaces the active transport. Safe to call concurrently +// with loadTransport. +func (dm *DistMemory) storeTransport(t DistTransport) { + dm.transport.Store(&distTransportSlot{t: t}) +} + // enumerateRemoteOnlyKeys returns keys present only on the remote side (best-effort, in-process only). func (dm *DistMemory) enumerateRemoteOnlyKeys(nodeID string, local []merkleKV) map[string]struct{} { //nolint:ireturn missing := make(map[string]struct{}) - ip, ok := dm.transport.(*InProcessTransport) + ip, ok := dm.loadTransport().(*InProcessTransport) if !ok { return missing } @@ -1288,7 +1335,12 @@ func (dm *DistMemory) enumerateRemoteOnlyKeys(nodeID string, local []merkleKV) m // fetchAndAdopt pulls a key from a remote node and adopts it if it's newer or absent locally. func (dm *DistMemory) fetchAndAdopt(ctx context.Context, nodeID, key string) { - it, ok, gerr := dm.transport.ForwardGet(ctx, nodeID, key) + transport := dm.loadTransport() + if transport == nil { + return + } + + it, ok, gerr := transport.ForwardGet(ctx, nodeID, key) if gerr != nil { // remote failure: ignore return } @@ -1555,7 +1607,7 @@ func (dm *DistMemory) shouldRebalance(sh *distShard, it *cache.Item) bool { //no // replicateNewReplicas scans for keys where this node is still primary but new replica owners were added since first observation. // It forwards the current item to newly added replicas (best-effort) and updates originalOwners snapshot. func (dm *DistMemory) replicateNewReplicas(ctx context.Context) { //nolint:ireturn - if dm.ring == nil || dm.transport == nil { + if dm.ring == nil || dm.loadTransport() == nil { return } @@ -1657,12 +1709,18 @@ func (dm *DistMemory) sendReplicaDiff( repls []cluster.NodeID, processed, limit int, ) int { //nolint:ireturn + transport := dm.loadTransport() + if transport == nil { + return processed + } + for _, rid := range repls { if rid == dm.localNode.ID { continue } - _ = dm.transport.ForwardSet(ctx, string(rid), it, false) + _ = transport.ForwardSet(ctx, string(rid), it, false) + dm.metrics.replicaFanoutSet.Add(1) dm.metrics.rebalancedKeys.Add(1) dm.metrics.rebalanceReplicaDiff.Add(1) @@ -1757,7 +1815,8 @@ func (dm *DistMemory) migrateIfNeeded(ctx context.Context, item *cache.Item) { / return } - if dm.transport == nil { + transport := dm.loadTransport() + if transport == nil { return } @@ -1765,7 +1824,7 @@ func (dm *DistMemory) migrateIfNeeded(ctx context.Context, item *cache.Item) { / dm.metrics.rebalancedKeys.Add(1) dm.metrics.rebalancedPrimary.Add(1) - _ = dm.transport.ForwardSet(ctx, string(owners[0]), item, true) + _ = transport.ForwardSet(ctx, string(owners[0]), item, true) // Update originalPrimary so we don't recount repeatedly. sh := dm.shardFor(item.Key) @@ -1908,7 +1967,7 @@ func (dm *DistMemory) initMembershipIfNeeded() { //nolint:ireturn // tryStartHTTP starts internal HTTP transport if not provided. func (dm *DistMemory) tryStartHTTP(ctx context.Context) { //nolint:ireturn - if dm.transport != nil || dm.nodeAddr == "" { + if dm.loadTransport() != nil || dm.nodeAddr == "" { return } @@ -1937,12 +1996,12 @@ func (dm *DistMemory) tryStartHTTP(ctx context.Context) { //nolint:ireturn return "", false } - dm.transport = NewDistHTTPTransport(2*time.Second, resolver) + dm.storeTransport(NewDistHTTPTransport(2*time.Second, resolver)) } // startHeartbeatIfEnabled launches heartbeat loop if configured. func (dm *DistMemory) startHeartbeatIfEnabled(ctx context.Context) { //nolint:ireturn - if dm.hbInterval > 0 && dm.transport != nil { + if dm.hbInterval > 0 && dm.loadTransport() != nil { stopCh := make(chan struct{}) dm.stopCh = stopCh @@ -2008,13 +2067,14 @@ func (dm *DistMemory) tryLocalGet(key string, idx int, oid cluster.NodeID) (*cac // tryRemoteGet attempts remote fetch for given owner; includes promotion + repair. func (dm *DistMemory) tryRemoteGet(ctx context.Context, key string, idx int, oid cluster.NodeID) (*cache.Item, bool) { //nolint:ireturn - if oid == dm.localNode.ID || dm.transport == nil { // skip local path or missing transport + transport := dm.loadTransport() + if oid == dm.localNode.ID || transport == nil { // skip local path or missing transport return nil, false } dm.metrics.forwardGet.Add(1) - it, ok, err := dm.transport.ForwardGet(ctx, string(oid), key) + it, ok, err := transport.ForwardGet(ctx, string(oid), key) if errors.Is(err, sentinel.ErrBackendNotFound) { // owner unreachable -> promotion scenario if idx == 0 { // primary missing dm.metrics.readPrimaryPromote.Add(1) @@ -2102,7 +2162,8 @@ func (dm *DistMemory) repairStaleOwners( chosen *cache.Item, staleOwners []cluster.NodeID, ) { //nolint:ireturn - if dm.transport == nil || chosen == nil { + transport := dm.loadTransport() + if transport == nil || chosen == nil { return } @@ -2111,13 +2172,14 @@ func (dm *DistMemory) repairStaleOwners( continue } - it, ok, err := dm.transport.ForwardGet(ctx, string(oid), key) + it, ok, err := transport.ForwardGet(ctx, string(oid), key) if err != nil { // skip unreachable continue } if !ok || it.Version < chosen.Version || (it.Version == chosen.Version && it.Origin > chosen.Origin) { - _ = dm.transport.ForwardSet(ctx, string(oid), chosen, false) + _ = transport.ForwardSet(ctx, string(oid), chosen, false) + dm.metrics.readRepair.Add(1) } } @@ -2133,7 +2195,12 @@ func (dm *DistMemory) fetchOwner(ctx context.Context, key string, idx int, oid c return nil, false } - it, ok, err := dm.transport.ForwardGet(ctx, string(oid), key) + transport := dm.loadTransport() + if transport == nil { + return nil, false + } + + it, ok, err := transport.ForwardGet(ctx, string(oid), key) if errors.Is(err, sentinel.ErrBackendNotFound) { // promotion if idx == 0 { dm.metrics.readPrimaryPromote.Add(1) @@ -2155,23 +2222,26 @@ func (dm *DistMemory) fetchOwner(ctx context.Context, key string, idx int, oid c // replicateTo sends writes to replicas (best-effort) returning ack count. func (dm *DistMemory) replicateTo(ctx context.Context, item *cache.Item, replicas []cluster.NodeID) int { //nolint:ireturn + transport := dm.loadTransport() + if transport == nil { + return 0 + } + acks := 0 for _, oid := range replicas { if oid == dm.localNode.ID { continue } - if dm.transport != nil { - err := dm.transport.ForwardSet(ctx, string(oid), item, false) - if err == nil { - acks++ + err := transport.ForwardSet(ctx, string(oid), item, false) + if err == nil { + acks++ - continue - } + continue + } - if errors.Is(err, sentinel.ErrBackendNotFound) { // queue hint for unreachable replica - dm.queueHint(string(oid), item) - } + if errors.Is(err, sentinel.ErrBackendNotFound) { // queue hint for unreachable replica + dm.queueHint(string(oid), item) } } @@ -2357,7 +2427,7 @@ func (dm *DistMemory) hintReplayLoop(ctx context.Context, stopCh <-chan struct{} } func (dm *DistMemory) replayHints(ctx context.Context) { // reduced cognitive complexity - if dm.transport == nil { + if dm.loadTransport() == nil { return } @@ -2401,7 +2471,12 @@ func (dm *DistMemory) processHint(ctx context.Context, nodeID string, entry hint return 1 } - err := dm.transport.ForwardSet(ctx, nodeID, entry.item, false) + transport := dm.loadTransport() + if transport == nil { + return 0 + } + + err := transport.ForwardSet(ctx, nodeID, entry.item, false) if err == nil { dm.metrics.hintedReplayed.Add(1) @@ -2514,7 +2589,7 @@ func (dm *DistMemory) gossipLoop(stopCh <-chan struct{}) { //nolint:ireturn } func (dm *DistMemory) runGossipTick() { //nolint:ireturn - if dm.membership == nil || dm.transport == nil { + if dm.membership == nil || dm.loadTransport() == nil { return } @@ -2545,7 +2620,7 @@ func (dm *DistMemory) runGossipTick() { //nolint:ireturn target := candidates[idxBig.Int64()] - ip, ok := dm.transport.(*InProcessTransport) + ip, ok := dm.loadTransport().(*InProcessTransport) if !ok { return } @@ -2672,26 +2747,29 @@ func (dm *DistMemory) repairRemoteReplica( chosen *cache.Item, oid cluster.NodeID, ) { // separated to reduce cyclomatic complexity //nolint:ireturn - if dm.transport == nil { // cannot repair remote + transport := dm.loadTransport() + if transport == nil { // cannot repair remote return } - it, ok, _ := dm.transport.ForwardGet(ctx, string(oid), key) + it, ok, _ := transport.ForwardGet(ctx, string(oid), key) if !ok || it.Version < chosen.Version || (it.Version == chosen.Version && it.Origin > chosen.Origin) { // stale - _ = dm.transport.ForwardSet(ctx, string(oid), chosen, false) + _ = transport.ForwardSet(ctx, string(oid), chosen, false) + dm.metrics.readRepair.Add(1) } } // handleForwardPrimary tries to forward a Set to the primary; returns (proceedAsPrimary,false) if promotion required. func (dm *DistMemory) handleForwardPrimary(ctx context.Context, owners []cluster.NodeID, item *cache.Item) (bool, error) { //nolint:ireturn - if dm.transport == nil { + transport := dm.loadTransport() + if transport == nil { return false, sentinel.ErrNotOwner } dm.metrics.forwardSet.Add(1) - errFwd := dm.transport.ForwardSet(ctx, string(owners[0]), item, true) + errFwd := transport.ForwardSet(ctx, string(owners[0]), item, true) switch { case errFwd == nil: return false, nil // forwarded successfully @@ -2806,7 +2884,9 @@ func (dm *DistMemory) applySet(ctx context.Context, item *cache.Item, replicate } owners := dm.ring.Lookup(item.Key) - if len(owners) <= 1 || dm.transport == nil { + transport := dm.loadTransport() + + if len(owners) <= 1 || transport == nil { return } @@ -2817,7 +2897,7 @@ func (dm *DistMemory) applySet(ctx context.Context, item *cache.Item, replicate continue } - _ = dm.transport.ForwardSet(ctx, string(oid), item, false) + _ = transport.ForwardSet(ctx, string(oid), item, false) } } @@ -2885,7 +2965,8 @@ func (dm *DistMemory) applyRemove(ctx context.Context, key string, replicate boo sh.tombs[key] = tombstone{version: nextVer, origin: string(dm.localNode.ID), at: time.Now()} dm.metrics.tombstonesActive.Store(dm.countTombstones()) - if !replicate || dm.ring == nil || dm.transport == nil { + transport := dm.loadTransport() + if !replicate || dm.ring == nil || transport == nil { return } @@ -2901,13 +2982,13 @@ func (dm *DistMemory) applyRemove(ctx context.Context, key string, replicate boo continue } - _ = dm.transport.ForwardRemove(ctx, string(oid), key, false) + _ = transport.ForwardRemove(ctx, string(oid), key, false) } } // runHeartbeatTick runs one heartbeat iteration (best-effort). func (dm *DistMemory) runHeartbeatTick(ctx context.Context) { //nolint:ireturn,revive - if dm.transport == nil || dm.membership == nil { + if dm.loadTransport() == nil || dm.membership == nil { return } @@ -2962,8 +3043,13 @@ func (dm *DistMemory) evaluateLiveness(ctx context.Context, now time.Time, node dm.metrics.nodesSuspect.Add(1) } + transport := dm.loadTransport() + if transport == nil { + return + } + ctxHealth, cancel := context.WithTimeout(ctx, dm.hbInterval/2) - err := dm.transport.Health(ctxHealth, string(node.ID)) + err := transport.Health(ctxHealth, string(node.ID)) cancel() diff --git a/pkg/backend/inmemory.go b/pkg/backend/inmemory.go index 9a040e0..28528c3 100644 --- a/pkg/backend/inmemory.go +++ b/pkg/backend/inmemory.go @@ -2,17 +2,16 @@ package backend import ( "context" - "sync" "github.com/hyp3rd/hypercache/internal/constants" "github.com/hyp3rd/hypercache/internal/sentinel" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) -// InMemory is a cache backend that stores the items in memory, leveraging a custom `ConcurrentMap`. +// InMemory is a cache backend that stores the items in memory, leveraging a +// custom sharded ConcurrentMap. Thread-safety is provided by the underlying +// ConcurrentMap; no backend-level mutex is needed. type InMemory struct { - sync.RWMutex // mutex to protect the cache from concurrent access - items cache.ConcurrentMap // map to store the items in the cache itemPoolManager *cache.ItemPoolManager // item pool manager to manage the item pool capacity int // capacity of the cache, limits the number of items that can be stored in the cache @@ -80,9 +79,6 @@ func (cacheBackend *InMemory) Set(_ context.Context, item *cache.Item) error { return err } - cacheBackend.Lock() - defer cacheBackend.Unlock() - cacheBackend.items.Set(item.Key, item) return nil @@ -90,10 +86,6 @@ func (cacheBackend *InMemory) Set(_ context.Context, item *cache.Item) error { // List returns a list of all items in the cache filtered and ordered by the given options. func (cacheBackend *InMemory) List(_ context.Context, filters ...IFilter) ([]*cache.Item, error) { - // Apply the filters - cacheBackend.RLock() - defer cacheBackend.RUnlock() - var err error items := make([]*cache.Item, 0, cacheBackend.items.Count()) diff --git a/pkg/cache/v2/cmap_test.go b/pkg/cache/v2/cmap_test.go index a32683f..f31bfe6 100644 --- a/pkg/cache/v2/cmap_test.go +++ b/pkg/cache/v2/cmap_test.go @@ -6,6 +6,11 @@ import ( "time" ) +const ( + concurrentWrites = 100 + concurrentReads = 50 +) + func TestNew(t *testing.T) { cm := New() if len(cm.shards) != ShardCount { @@ -248,7 +253,7 @@ func TestConcurrentAccess(t *testing.T) { wg := sync.WaitGroup{} // Concurrent writes - for i := range 100 { + for i := range concurrentWrites { wg.Add(1) go func(i int) { @@ -263,7 +268,7 @@ func TestConcurrentAccess(t *testing.T) { } // Concurrent reads - for i := range 50 { + for i := range concurrentReads { wg.Add(1) go func(i int) { diff --git a/pkg/stats/histogramcollector.go b/pkg/stats/histogramcollector.go index 136111a..ee5270e 100644 --- a/pkg/stats/histogramcollector.go +++ b/pkg/stats/histogramcollector.go @@ -4,164 +4,323 @@ import ( "math" "slices" "sync" + "sync/atomic" "github.com/hyp3rd/hypercache/internal/constants" ) -// HistogramStatsCollector is a stats collector that collects histogram stats. +// DefaultSampleCapacity is the per-stat ring buffer size used by +// NewHistogramStatsCollector. Bounded so memory does not grow without bound +// under sustained recording. +const DefaultSampleCapacity = 4096 + +// HistogramStatsCollector records statistics with per-stat atomic aggregates +// and a bounded per-stat sample window for percentile computation. +// +// Hot-path Incr/Decr/Timing/Gauge/Histogram updates use atomic counters and a +// best-effort TryLock around a fixed-size ring buffer. There is no global +// lock on the recording path; the shard map uses sync.Map for read-mostly +// lookup. Memory usage is bounded. +// +// Mean, Min, Max, Sum, and Count are exact lifetime aggregates. Median, +// Percentile, Variance, and the Values slice exposed by GetStats are +// computed over a recent-window snapshot whose size is configurable via +// NewHistogramStatsCollectorWithCapacity (default DefaultSampleCapacity). +// +// Performance characteristics: contention is now partitioned per stat key +// rather than globally serialized. A worst-case microbenchmark with all +// goroutines hammering a single stat key sees ~150 ns/op due to cache-line +// ping-pong on the shard's atomics; in realistic workloads where calls +// spread across multiple stats (cache_hits, evictions, set_count, etc.) +// throughput scales with the number of distinct keys. type HistogramStatsCollector struct { - mu sync.RWMutex // mutex to protect concurrent access to the stats - stats map[string][]int64 + stats sync.Map // map[string]*statShard + sampleCap int } -// NewHistogramStatsCollector creates a new histogram stats collector. +// statShard holds the per-stat lock-free aggregates and the sample ring buffer. +type statShard struct { + count atomic.Int64 + sum atomic.Int64 + // minV/maxV track the lifetime extremes. They start at the int64 sentinels + // MaxInt64/MinInt64; the first recorded value will CAS-update both. + minV atomic.Int64 + maxV atomic.Int64 + + samplesMu sync.Mutex + samples []int64 // pre-allocated to sampleCap + pos int // next write index + written int // number of samples actually written, capped at len(samples) +} + +// NewHistogramStatsCollector creates a collector with the default sample window. func NewHistogramStatsCollector() *HistogramStatsCollector { - return &HistogramStatsCollector{ - stats: make(map[string][]int64), + return NewHistogramStatsCollectorWithCapacity(DefaultSampleCapacity) +} + +// NewHistogramStatsCollectorWithCapacity creates a collector with a custom +// per-stat sample window. capacity <= 0 falls back to DefaultSampleCapacity. +func NewHistogramStatsCollectorWithCapacity(capacity int) *HistogramStatsCollector { + if capacity <= 0 { + capacity = DefaultSampleCapacity } + + return &HistogramStatsCollector{sampleCap: capacity} } // Incr increments the count of a statistic by the given value. func (c *HistogramStatsCollector) Incr(stat constants.Stat, value int64) { - c.mu.Lock() - defer c.mu.Unlock() - - c.stats[stat.String()] = append(c.stats[stat.String()], value) + c.record(stat.String(), value) } // Decr decrements the count of a statistic by the given value. func (c *HistogramStatsCollector) Decr(stat constants.Stat, value int64) { - c.mu.Lock() - defer c.mu.Unlock() - - c.stats[stat.String()] = append(c.stats[stat.String()], -value) + c.record(stat.String(), -value) } // Timing records the time it took for an event to occur. func (c *HistogramStatsCollector) Timing(stat constants.Stat, value int64) { - c.mu.Lock() - defer c.mu.Unlock() - - c.stats[stat.String()] = append(c.stats[stat.String()], value) + c.record(stat.String(), value) } // Gauge records the current value of a statistic. func (c *HistogramStatsCollector) Gauge(stat constants.Stat, value int64) { - c.mu.Lock() - defer c.mu.Unlock() - - c.stats[stat.String()] = append(c.stats[stat.String()], value) + c.record(stat.String(), value) } // Histogram records the statistical distribution of a set of values. func (c *HistogramStatsCollector) Histogram(stat constants.Stat, value int64) { - c.mu.Lock() - defer c.mu.Unlock() - - c.stats[stat.String()] = append(c.stats[stat.String()], value) + c.record(stat.String(), value) } -// Mean returns the mean value of a statistic. -func (c *HistogramStatsCollector) Mean(stat constants.Stat) float64 { - values := c.stats[stat.String()] - if len(values) == 0 { - return 0 +// snapshotSorted returns a sorted copy of the recent sample window for shard. +// The shared backing array is never mutated; callers can sort/scan freely. +func (s *statShard) snapshotSorted() []int64 { + s.samplesMu.Lock() + + written := s.written + out := make([]int64, written) + + if written > 0 { + if written < len(s.samples) { + copy(out, s.samples[:written]) + } else { + // ring buffer is full; reorder oldest-first so the slice is a + // faithful temporal window (not strictly required for sort, but + // useful for any consumer that scans Values directly). + tail := s.samples[s.pos:] + head := s.samples[:s.pos] + + copy(out, tail) + copy(out[len(tail):], head) + } } - var sum int64 + s.samplesMu.Unlock() - for _, value := range values { - sum += value + if written > 0 { + slices.Sort(out) } - return float64(sum) / float64(len(values)) + return out } -// Median returns the median value of a statistic. -func (c *HistogramStatsCollector) Median(stat constants.Stat) float64 { - values := c.stats[stat.String()] - if len(values) == 0 { +// Mean returns the lifetime mean of all recorded values. +func (c *HistogramStatsCollector) Mean(stat constants.Stat) float64 { + shard := c.shard(stat.String()) + + count := shard.count.Load() + if count == 0 { return 0 } - slices.Sort(values) + return float64(shard.sum.Load()) / float64(count) +} - mid := len(values) / 2 - if len(values)%2 == 0 { - return float64(values[mid-1]+values[mid]) / 2 - } +// Median returns the median of the recent sample window. +func (c *HistogramStatsCollector) Median(stat constants.Stat) float64 { + shard := c.shard(stat.String()) - return float64(values[mid]) + return medianOf(shard.snapshotSorted()) } -// Percentile returns the pth percentile value of a statistic. +// Percentile returns the pth percentile of the recent sample window. +// percentile must be in [0, 1]; values outside that range are clamped. func (c *HistogramStatsCollector) Percentile(stat constants.Stat, percentile float64) float64 { - values := c.stats[stat.String()] - if len(values) == 0 { - return 0 - } - - slices.Sort(values) + shard := c.shard(stat.String()) + sorted := shard.snapshotSorted() - index := int(float64(len(values)) * percentile) - - return float64(values[index]) + return percentileOf(sorted, percentile) } -// GetStats returns the stats collected by the stats collector. -// It calculates the mean, median, min, max, count, sum, and variance for each stat. -// It returns a map where the keys are the stat names and the values are the stat values. +// GetStats returns aggregated statistics. Mean, Min, Max, Sum, and Count are +// exact lifetime values; Median, Variance, and Values are over the recent +// sample window. func (c *HistogramStatsCollector) GetStats() Stats { - c.mu.RLock() - defer c.mu.RUnlock() + out := make(Stats) + + c.stats.Range(func(key, value any) bool { + k, ok := key.(string) + if !ok { + return true + } - stats := make(Stats) - for stat, values := range c.stats { - mean := c.Mean(constants.Stat(stat)) - median := c.Median(constants.Stat(stat)) + shard, ok := value.(*statShard) + if !ok { + return true + } - slices.Sort(values) + count := shard.count.Load() + if count == 0 { + return true + } - minVal := values[0] - maxVal := values[len(values)-1] + sum := shard.sum.Load() + minVal := shard.minV.Load() + maxVal := shard.maxV.Load() + sorted := shard.snapshotSorted() + mean := float64(sum) / float64(count) - stats[stat] = &Stat{ + out[k] = &Stat{ Mean: mean, - Median: median, + Median: medianOf(sorted), Min: minVal, Max: maxVal, - Values: values, - Count: len(values), - Sum: sum(values), - Variance: variance(values, mean), + Values: sorted, + Count: int(count), + Sum: sum, + Variance: varianceOf(sorted, mean), } + + return true + }) + + return out +} + +// shard returns (and lazily creates) the statShard for a key. Hot path uses +// sync.Map.Load to avoid the cache-line ping-pong of an RWMutex.RLock. +func (c *HistogramStatsCollector) shard(key string) *statShard { + if existing, ok := c.stats.Load(key); ok { + shard, ok := existing.(*statShard) + if !ok { + panic(errCorruptStatsMap) + } + + return shard } - return stats + fresh := &statShard{samples: make([]int64, c.sampleCap)} + fresh.minV.Store(math.MaxInt64) + fresh.maxV.Store(math.MinInt64) + + actual, _ := c.stats.LoadOrStore(key, fresh) + + shard, ok := actual.(*statShard) + if !ok { + panic(errCorruptStatsMap) + } + + return shard } -// sum returns the sum of a set of values. -func sum(values []int64) int64 { - var sum int64 +// errCorruptStatsMap signals a programming error: the stats sync.Map only +// stores *statShard values; a failed assertion means an unrelated writer +// has corrupted it. +const errCorruptStatsMap = "histogramcollector: stats map contains non-*statShard value" + +// record is the hot path: atomic aggregate update + bounded ring write. +// +// The aggregates (count, sum, min, max) are always exact. The sample ring +// buffer is best-effort: when samplesMu is contended we TryLock and drop the +// sample silently. Under heavy concurrent recording this trades a fraction of +// percentile fidelity (samples are skipped, not blocked) for stable hot-path +// latency. Percentiles remain statistically meaningful because the dropped +// samples are uniformly distributed across recorders. +func (c *HistogramStatsCollector) record(key string, value int64) { + shard := c.shard(key) + + shard.count.Add(1) + shard.sum.Add(value) + + for { + cur := shard.minV.Load() + if value >= cur || shard.minV.CompareAndSwap(cur, value) { + break + } + } - for _, value := range values { - sum += value + for { + cur := shard.maxV.Load() + if value <= cur || shard.maxV.CompareAndSwap(cur, value) { + break + } + } + + if !shard.samplesMu.TryLock() { + return } - return sum + shard.samples[shard.pos] = value + shard.pos = (shard.pos + 1) % len(shard.samples) + + if shard.written < len(shard.samples) { + shard.written++ + } + + shard.samplesMu.Unlock() } -// variance returns the variance of a set of values. -func variance(values []int64, mean float64) float64 { +// medianOf returns the median of a sorted slice. Empty slice returns 0. +func medianOf(sorted []int64) float64 { + n := len(sorted) + if n == 0 { + return 0 + } + + if n%2 == 1 { + return float64(sorted[n/2]) + } + + return float64(sorted[n/2-1]+sorted[n/2]) / 2 +} + +// percentileOf returns the pth percentile of a sorted slice using +// nearest-rank. Empty slice returns 0; p is clamped to [0, 1]. +func percentileOf(sorted []int64, percentile float64) float64 { + n := len(sorted) + if n == 0 { + return 0 + } + + switch { + case percentile <= 0: + return float64(sorted[0]) + case percentile >= 1: + return float64(sorted[n-1]) + } + + idx := int(float64(n) * percentile) + if idx >= n { + idx = n - 1 + } + + return float64(sorted[idx]) +} + +// varianceOf returns the population variance of values around mean. +func varianceOf(values []int64, mean float64) float64 { if len(values) == 0 { return 0 } - var variance float64 + var acc float64 for _, value := range values { - variance += math.Pow(float64(value)-mean, 2) + diff := float64(value) - mean + + acc += diff * diff } - return variance / float64(len(values)) + return acc / float64(len(values)) } diff --git a/pkg/stats/histogramcollector_bench_test.go b/pkg/stats/histogramcollector_bench_test.go index f89bd19..2ff2982 100644 --- a/pkg/stats/histogramcollector_bench_test.go +++ b/pkg/stats/histogramcollector_bench_test.go @@ -48,6 +48,34 @@ func BenchmarkHistogramTimingParallel(b *testing.B) { }) } +// BenchmarkHistogramRealisticParallel models a realistic workload where many +// stat keys spread the contention. The single-key Parallel benchmark above is +// the synthetic worst case; this benchmark is what production looks like. +func BenchmarkHistogramRealisticParallel(b *testing.B) { + keys := []constants.Stat{ + constants.StatIncr, + constants.StatDecr, + constants.StatTiming, + constants.StatGauge, + constants.StatHistogram, + } + + c := NewHistogramStatsCollector() + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + var i int + + for pb.Next() { + c.Incr(keys[i%len(keys)], 1) + + i++ + } + }) +} + // BenchmarkHistogramGetStats measures the read path. Currently sorts the // backing slice in-place under RLock (race) and re-sorts per-stat multiple times. func BenchmarkHistogramGetStats(b *testing.B) { diff --git a/pkg/stats/histogramcollector_test.go b/pkg/stats/histogramcollector_test.go new file mode 100644 index 0000000..75559b8 --- /dev/null +++ b/pkg/stats/histogramcollector_test.go @@ -0,0 +1,346 @@ +package stats + +import ( + "runtime" + "sync" + "sync/atomic" + "testing" + + "github.com/hyp3rd/hypercache/internal/constants" +) + +func TestHistogramStatsCollector_BasicAggregates(t *testing.T) { + c := NewHistogramStatsCollector() + + c.Incr(constants.StatIncr, 10) + c.Incr(constants.StatIncr, 20) + c.Incr(constants.StatIncr, 30) + + if got := c.Mean(constants.StatIncr); got != 20 { + t.Errorf("Mean = %v, want 20", got) + } + + allStats := c.GetStats() + + stat, ok := allStats[constants.StatIncr.String()] + if !ok { + t.Fatalf("missing stat key") + } + + if stat.Min != 10 { + t.Errorf("Min = %d, want 10", stat.Min) + } + + if stat.Max != 30 { + t.Errorf("Max = %d, want 30", stat.Max) + } + + if stat.Sum != 60 { + t.Errorf("Sum = %d, want 60", stat.Sum) + } + + if stat.Count != 3 { + t.Errorf("Count = %d, want 3", stat.Count) + } + + if stat.Mean != 20 { + t.Errorf("Mean = %v, want 20", stat.Mean) + } +} + +func TestHistogramStatsCollector_DecrStoresNegative(t *testing.T) { + c := NewHistogramStatsCollector() + + c.Incr(constants.StatIncr, 5) + c.Decr(constants.StatIncr, 2) // stored as -2 + + stat := c.GetStats()[constants.StatIncr.String()] + if stat.Sum != 3 { + t.Errorf("Sum = %d, want 3", stat.Sum) + } + + if stat.Min != -2 { + t.Errorf("Min = %d, want -2", stat.Min) + } + + if stat.Max != 5 { + t.Errorf("Max = %d, want 5", stat.Max) + } +} + +func TestHistogramStatsCollector_Median(t *testing.T) { + c := NewHistogramStatsCollector() + + for _, v := range []int64{3, 1, 4, 1, 5, 9, 2, 6} { + c.Histogram(constants.StatHistogram, v) + } + + // sorted: 1,1,2,3,4,5,6,9 -> median = (3+4)/2 = 3.5 + if got := c.Median(constants.StatHistogram); got != 3.5 { + t.Errorf("Median = %v, want 3.5", got) + } + + c.Histogram(constants.StatHistogram, 7) + + // sorted (9 elems): 1,1,2,3,4,5,6,7,9 -> median = 4 + if got := c.Median(constants.StatHistogram); got != 4 { + t.Errorf("Median (odd n) = %v, want 4", got) + } +} + +func TestHistogramStatsCollector_Percentile(t *testing.T) { + c := NewHistogramStatsCollector() + + for i := int64(1); i <= 100; i++ { + c.Histogram(constants.StatHistogram, i) + } + + cases := []struct { + percentile float64 + want float64 + }{ + {0, 1}, + {0.5, 51}, // index = 50, sorted[50] = 51 + {0.99, 100}, + {1, 100}, + } + + for _, tc := range cases { + if got := c.Percentile(constants.StatHistogram, tc.percentile); got != tc.want { + t.Errorf("Percentile(%v) = %v, want %v", tc.percentile, got, tc.want) + } + } +} + +func TestHistogramStatsCollector_EmptyStat(t *testing.T) { + c := NewHistogramStatsCollector() + + // querying an unrecorded stat must return zero values, not panic + if got := c.Mean(constants.StatIncr); got != 0 { + t.Errorf("Mean(empty) = %v, want 0", got) + } + + if got := c.Median(constants.StatIncr); got != 0 { + t.Errorf("Median(empty) = %v, want 0", got) + } + + if got := c.Percentile(constants.StatIncr, 0.5); got != 0 { + t.Errorf("Percentile(empty) = %v, want 0", got) + } + + if len(c.GetStats()) != 0 { + t.Errorf("GetStats(empty) returned non-empty result") + } +} + +func TestHistogramStatsCollector_BoundedSamples(t *testing.T) { + const cap = 8 + + c := NewHistogramStatsCollectorWithCapacity(cap) + + // Write more than cap samples; sample buffer must stay at cap. + for i := range int64(100) { + c.Histogram(constants.StatHistogram, i) + } + + stat := c.GetStats()[constants.StatHistogram.String()] + if stat == nil { + t.Fatalf("missing stat") + } + + if stat.Count != 100 { + t.Errorf("lifetime Count = %d, want 100", stat.Count) + } + + if got := len(stat.Values); got != cap { + t.Errorf("len(Values) = %d, want %d (bounded)", got, cap) + } + + // Min/Max must still reflect the lifetime range, not just the window. + if stat.Min != 0 { + t.Errorf("lifetime Min = %d, want 0", stat.Min) + } + + if stat.Max != 99 { + t.Errorf("lifetime Max = %d, want 99", stat.Max) + } +} + +// TestHistogramStatsCollector_ConcurrentRecord is the race-detector regression +// test. Concurrent recorders + a concurrent reader of GetStats/Mean must not +// trip the race detector (which the previous implementation would, since it +// sorted shared backing arrays in-place under RLock). +func TestHistogramStatsCollector_ConcurrentRecord(t *testing.T) { + c := NewHistogramStatsCollector() + + const writers = 8 + + const perWriter = 5_000 + + var writersWG sync.WaitGroup + + for w := range writers { + writersWG.Go(func() { + for i := range perWriter { + c.Incr(constants.StatIncr, int64(w*perWriter+i)) + } + }) + } + + // Reader runs until writers finish; signaled via stop channel. + stop := make(chan struct{}) + + var readerWG sync.WaitGroup + + readerWG.Go(func() { + for { + select { + case <-stop: + return + default: + _ = c.GetStats() + _ = c.Mean(constants.StatIncr) + _ = c.Percentile(constants.StatIncr, 0.99) + } + } + }) + + writersWG.Wait() + close(stop) + readerWG.Wait() + + stat := c.GetStats()[constants.StatIncr.String()] + if stat == nil { + t.Fatalf("missing stat after concurrent recording") + } + + wantCount := int64(writers * perWriter) + if int64(stat.Count) != wantCount { + t.Errorf("Count = %d, want %d", stat.Count, wantCount) + } +} + +// TestHistogramStatsCollector_GetStatsSnapshotIsolated ensures GetStats does +// not mutate the live sample buffer. The previous implementation called +// slices.Sort on the live buffer, racing with concurrent writers. +func TestHistogramStatsCollector_GetStatsSnapshotIsolated(t *testing.T) { + c := NewHistogramStatsCollector() + + for i := range int64(32) { + c.Histogram(constants.StatHistogram, i) + } + + first := c.GetStats()[constants.StatHistogram.String()] + firstValues := append([]int64(nil), first.Values...) + + // mutate the returned slice; the next snapshot must be unaffected. + for i := range first.Values { + first.Values[i] = -999 + } + + second := c.GetStats()[constants.StatHistogram.String()] + for i, v := range second.Values { + if v != firstValues[i] { + t.Errorf("GetStats returned shared slice: idx %d = %d, want %d", i, v, firstValues[i]) + + break + } + } +} + +// TestHistogramStatsCollector_NoMemoryLeak verifies the bounded sample window +// keeps memory usage flat under sustained recording. The previous +// implementation appended forever and would grow unbounded. +func TestHistogramStatsCollector_NoMemoryLeak(t *testing.T) { + if testing.Short() { + t.Skip("skipping memory soak in short mode") + } + + const cap = 1024 + + c := NewHistogramStatsCollectorWithCapacity(cap) + + // Prime the ring buffer. + for i := range cap { + c.Histogram(constants.StatHistogram, int64(i)) + } + + runtime.GC() + + var before runtime.MemStats + + runtime.ReadMemStats(&before) + + // Record 1M more samples; ring buffer is full so allocations should be ~0. + const extra = 1_000_000 + + for i := range extra { + c.Histogram(constants.StatHistogram, int64(i)) + } + + runtime.GC() + + var after runtime.MemStats + + runtime.ReadMemStats(&after) + + // Heap should not grow by more than a small constant (allow 1MB slack for + // runtime overhead, GC bookkeeping, etc.). + const slack = 1 << 20 + + growth := int64(after.HeapAlloc) - int64(before.HeapAlloc) + if growth > slack { + t.Errorf("heap grew by %d bytes during steady-state recording (want <= %d)", growth, slack) + } + + // And lifetime count is still exact. + stat := c.GetStats()[constants.StatHistogram.String()] + want := int64(cap + extra) + + if int64(stat.Count) != want { + t.Errorf("Count = %d, want %d", stat.Count, want) + } +} + +// TestHistogramStatsCollector_AtomicMinMaxRace exercises the CAS loops in +// record() under concurrent extreme values. +func TestHistogramStatsCollector_AtomicMinMaxRace(t *testing.T) { + c := NewHistogramStatsCollector() + + var wg sync.WaitGroup + + const goroutines = 16 + + const perGoroutine = 1000 + + var seen atomic.Int64 + + for g := range goroutines { + wg.Go(func() { + for i := range perGoroutine { + value := int64(g*perGoroutine + i) + c.Incr(constants.StatIncr, value) + seen.Add(1) + } + }) + } + + wg.Wait() + + stat := c.GetStats()[constants.StatIncr.String()] + if stat == nil { + t.Fatalf("missing stat") + } + + if stat.Min != 0 { + t.Errorf("Min = %d, want 0", stat.Min) + } + + if stat.Max != int64(goroutines*perGoroutine-1) { + t.Errorf("Max = %d, want %d", stat.Max, goroutines*perGoroutine-1) + } + + if int64(stat.Count) != seen.Load() { + t.Errorf("Count = %d, want %d", stat.Count, seen.Load()) + } +} diff --git a/pool.go b/pool.go index b90b7fe..9b225f6 100644 --- a/pool.go +++ b/pool.go @@ -8,7 +8,18 @@ import ( type JobFunc func() error // WorkerPool is a pool of workers that can execute jobs concurrently. +// +// Enqueue is safe to call concurrently with Shutdown. After Shutdown returns, +// further Enqueue calls silently drop the job — this prevents races during +// graceful cache shutdown where background loops (expiration, eviction) may +// still attempt to enqueue work after Stop() has begun. type WorkerPool struct { + // shutdownMu protects the closed flag and serializes Shutdown vs Enqueue + // so Shutdown cannot close pool.jobs while an Enqueue is mid-send. + // Enqueue takes RLock (concurrent senders allowed); Shutdown takes Lock. + shutdownMu sync.RWMutex + closed bool + workers int jobs chan JobFunc wg sync.WaitGroup @@ -30,17 +41,37 @@ func NewWorkerPool(workers int) *WorkerPool { return pool } -// Enqueue adds a job to the worker pool. +// Enqueue adds a job to the worker pool. If the pool has been shut down, +// the job is silently dropped (see WorkerPool docstring). func (pool *WorkerPool) Enqueue(job JobFunc) { + pool.shutdownMu.RLock() + defer pool.shutdownMu.RUnlock() + + if pool.closed { + return + } + pool.wg.Add(1) pool.jobs <- job } -// Shutdown shuts down the worker pool. It waits for all jobs to finish. +// Shutdown shuts down the worker pool. It waits for all enqueued jobs to +// finish before returning. Idempotent — repeat calls are no-ops. func (pool *WorkerPool) Shutdown() { - // Stop accepting new jobs and let workers drain the queue + pool.shutdownMu.Lock() + + if pool.closed { + pool.shutdownMu.Unlock() + + return + } + + pool.closed = true + // Close jobs while holding the write lock so no Enqueue can race the close. close(pool.jobs) + pool.shutdownMu.Unlock() + // Wait for all enqueued jobs to complete pool.wg.Wait() // Now signal any lingering workers to exit select loop diff --git a/pool_test.go b/pool_test.go index 77be8f6..aa21fa8 100644 --- a/pool_test.go +++ b/pool_test.go @@ -160,15 +160,23 @@ func TestWorkerPool_NegativeResizeDoesNothing(t *testing.T) { pool.Shutdown() } -func TestWorkerPool_EnqueueAfterShutdownPanics(t *testing.T) { +// TestWorkerPool_EnqueueAfterShutdownDrops verifies the post-fix contract: +// Enqueue is safe to call after Shutdown and silently drops the job. +// +// Background: previously Enqueue panicked (send on closed channel), which +// surfaced under -race -count=N when expiration/eviction goroutines tried to +// schedule work mid-shutdown. The new contract trades a "loud failure on +// programming error" for "no panics during graceful shutdown races.". +func TestWorkerPool_EnqueueAfterShutdownDrops(t *testing.T) { pool := NewWorkerPool(1) pool.Shutdown() - defer func() { - if r := recover(); r == nil { - t.Errorf("expected panic when enqueuing after shutdown") - } - }() + pool.Enqueue(func() error { + t.Errorf("job should not run after shutdown") - pool.Enqueue(func() error { return nil }) + return nil + }) + + // Repeat Shutdown is idempotent. + pool.Shutdown() } diff --git a/tests/benchmark/hypercache_concurrency_benchmark_test.go b/tests/benchmark/hypercache_concurrency_benchmark_test.go index 6faa55f..b2cc20b 100644 --- a/tests/benchmark/hypercache_concurrency_benchmark_test.go +++ b/tests/benchmark/hypercache_concurrency_benchmark_test.go @@ -93,7 +93,10 @@ func BenchmarkHyperCache_GetOrSetParallel(b *testing.B) { // BenchmarkHyperCache_MixedParallel simulates an 80% read / 20% write workload. func BenchmarkHyperCache_MixedParallel(b *testing.B) { - config := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + config, err := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + if err != nil { + b.Fatal(err) + } config.HyperCacheOptions = []hypercache.Option[backend.InMemory]{ hypercache.WithEvictionAlgorithm[backend.InMemory]("lru"), diff --git a/tests/benchmark/hypercache_get_benchmark_test.go b/tests/benchmark/hypercache_get_benchmark_test.go index b92c0ad..288f353 100644 --- a/tests/benchmark/hypercache_get_benchmark_test.go +++ b/tests/benchmark/hypercache_get_benchmark_test.go @@ -25,7 +25,10 @@ func BenchmarkHyperCache_Get(b *testing.B) { func BenchmarkHyperCache_Get_ProactiveEviction(b *testing.B) { // Create a new HyperCache with a capacity of 1000 - config := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + config, err := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + if err != nil { + b.Fatal(err) + } config.HyperCacheOptions = []hypercache.Option[backend.InMemory]{ hypercache.WithEvictionInterval[backend.InMemory](0), diff --git a/tests/benchmark/hypercache_set_benchmark_test.go b/tests/benchmark/hypercache_set_benchmark_test.go index d141806..b2abebf 100644 --- a/tests/benchmark/hypercache_set_benchmark_test.go +++ b/tests/benchmark/hypercache_set_benchmark_test.go @@ -25,7 +25,10 @@ func BenchmarkHyperCache_Set(b *testing.B) { func BenchmarkHyperCache_Set_Proactive_Eviction(b *testing.B) { // Create a new HyperCache with a capacity of 100000 - config := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + config, err := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + if err != nil { + b.Fatal(err) + } config.HyperCacheOptions = []hypercache.Option[backend.InMemory]{ hypercache.WithEvictionInterval[backend.InMemory](0), diff --git a/tests/benchmarkdist/hypercache_dist_benchmark_test.go b/tests/benchmarkdist/hypercache_dist_benchmark_test.go index 44dda2e..900e8f0 100644 --- a/tests/benchmarkdist/hypercache_dist_benchmark_test.go +++ b/tests/benchmarkdist/hypercache_dist_benchmark_test.go @@ -5,7 +5,7 @@ import ( "strconv" "testing" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -19,9 +19,20 @@ func BenchmarkDistMemory_Set(b *testing.B) { n2, _ := backend.NewDistMemory(ctx, append(opts, backend.WithDistNode("N2", "N2"))...) n3, _ := backend.NewDistMemory(ctx, append(opts, backend.WithDistNode("N3", "N3"))...) - d1 := any(n1).(*backend.DistMemory) - d2 := any(n2).(*backend.DistMemory) - d3 := any(n3).(*backend.DistMemory) + d1, ok := any(n1).(*backend.DistMemory) + if !ok { + b.Fatalf("failed to cast n1 to *backend.DistMemory") + } + + d2, ok := any(n2).(*backend.DistMemory) + if !ok { + b.Fatalf("failed to cast n2 to *backend.DistMemory") + } + + d3, ok := any(n3).(*backend.DistMemory) + if !ok { + b.Fatalf("failed to cast n3 to *backend.DistMemory") + } d1.SetTransport(transport) d2.SetTransport(transport) @@ -49,9 +60,20 @@ func BenchmarkDistMemory_Get(b *testing.B) { n2, _ := backend.NewDistMemory(ctx, append(opts, backend.WithDistNode("N2", "N2"))...) n3, _ := backend.NewDistMemory(ctx, append(opts, backend.WithDistNode("N3", "N3"))...) - d1 := any(n1).(*backend.DistMemory) - d2 := any(n2).(*backend.DistMemory) - d3 := any(n3).(*backend.DistMemory) + d1, ok := any(n1).(*backend.DistMemory) + if !ok { + b.Fatalf("failed to cast n1 to *backend.DistMemory") + } + + d2, ok := any(n2).(*backend.DistMemory) + if !ok { + b.Fatalf("failed to cast n2 to *backend.DistMemory") + } + + d3, ok := any(n3).(*backend.DistMemory) + if !ok { + b.Fatalf("failed to cast n3 to *backend.DistMemory") + } d1.SetTransport(transport) d2.SetTransport(transport) diff --git a/tests/hypercache_distmemory_failure_recovery_test.go b/tests/hypercache_distmemory_failure_recovery_test.go index 6f2e851..a6d0beb 100644 --- a/tests/hypercache_distmemory_failure_recovery_test.go +++ b/tests/hypercache_distmemory_failure_recovery_test.go @@ -43,8 +43,18 @@ func TestDistFailureRecovery(t *testing.T) { //nolint:paralleltest backend.WithDistHintMaxPerNode(50), ) - b1 := b1i.(*backend.DistMemory) - b2 := b2i.(*backend.DistMemory) + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) transport.Register(b1) transport.Register(b2) diff --git a/tests/hypercache_distmemory_heartbeat_sampling_test.go b/tests/hypercache_distmemory_heartbeat_sampling_test.go index 157df69..e6d4fbc 100644 --- a/tests/hypercache_distmemory_heartbeat_sampling_test.go +++ b/tests/hypercache_distmemory_heartbeat_sampling_test.go @@ -34,9 +34,24 @@ func TestHeartbeatSamplingAndTransitions(t *testing.T) { //nolint:paralleltest b2i, _ := backend.NewDistMemory(ctx, backend.WithDistMembership(membership, n2), backend.WithDistTransport(transport)) b3i, _ := backend.NewDistMemory(ctx, backend.WithDistMembership(membership, n3), backend.WithDistTransport(transport)) - b1 := b1i.(*backend.DistMemory) - b2 := b2i.(*backend.DistMemory) - b3 := b3i.(*backend.DistMemory) + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) transport.Register(b1) transport.Register(b2) diff --git a/tests/hypercache_distmemory_heartbeat_test.go b/tests/hypercache_distmemory_heartbeat_test.go index a48d08b..f99dbb7 100644 --- a/tests/hypercache_distmemory_heartbeat_test.go +++ b/tests/hypercache_distmemory_heartbeat_test.go @@ -12,9 +12,13 @@ import ( // TestDistMemoryHeartbeatLiveness spins up three nodes with a fast heartbeat interval // and validates suspect -> removal transitions plus success/failure metrics. func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest - interval := 30 * time.Millisecond - suspectAfter := 2 * interval - deadAfter := 4 * interval + // Intervals chosen so the test tolerates the 3-5x slowdown imposed by + // the race detector. Previous values (interval=30ms, dead=120ms) were + // tight enough that a delayed heartbeat tick could push *alive* nodes + // past deadAfter under -race, removing them from membership. + interval := 80 * time.Millisecond + suspectAfter := 4 * interval // 320ms + deadAfter := 8 * interval // 640ms ring := cluster.NewRing(cluster.WithReplication(1)) membership := cluster.NewMembership(ring) @@ -36,7 +40,12 @@ func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest t.Fatalf("b1: %v", err) } - b1 := b1i.(*backend.DistMemory) //nolint:forcetypeassert + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) // add peers (without heartbeat loops themselves) b2i, err := backend.NewDistMemory( @@ -48,7 +57,12 @@ func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest t.Fatalf("b2: %v", err) } - b2 := b2i.(*backend.DistMemory) //nolint:forcetypeassert + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b2) b3i, err := backend.NewDistMemory( context.TODO(), @@ -59,14 +73,19 @@ func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest t.Fatalf("b3: %v", err) } - b3 := b3i.(*backend.DistMemory) //nolint:forcetypeassert + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b3) transport.Register(b1) transport.Register(b2) transport.Register(b3) // Wait until heartbeat marks peers alive (initial success probes) - deadline := time.Now().Add(500 * time.Millisecond) + deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { aliveCount := 0 for _, n := range membership.List() { @@ -79,7 +98,7 @@ func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest break } - time.Sleep(10 * time.Millisecond) + time.Sleep(20 * time.Millisecond) } // Simulate node2 becoming unresponsive by removing it from transport registry. @@ -149,8 +168,4 @@ func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest if m.NodesRemoved == 0 { t.Errorf("expected nodes removed metric > 0") } - - _ = b1.Stop(context.Background()) - _ = b2.Stop(context.Background()) - _ = b3.Stop(context.Background()) } diff --git a/tests/hypercache_distmemory_hint_caps_test.go b/tests/hypercache_distmemory_hint_caps_test.go index e506c88..b7afeae 100644 --- a/tests/hypercache_distmemory_hint_caps_test.go +++ b/tests/hypercache_distmemory_hint_caps_test.go @@ -38,8 +38,18 @@ func TestHintGlobalCaps(t *testing.T) { //nolint:paralleltest backend.WithDistReplication(2), ) - b1 := b1i.(*backend.DistMemory) - b2 := b2i.(*backend.DistMemory) + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) transport.Register(b1) diff --git a/tests/hypercache_distmemory_hinted_handoff_test.go b/tests/hypercache_distmemory_hinted_handoff_test.go index 2d25abb..ef54596 100644 --- a/tests/hypercache_distmemory_hinted_handoff_test.go +++ b/tests/hypercache_distmemory_hinted_handoff_test.go @@ -9,7 +9,7 @@ import ( "time" "github.com/hyp3rd/hypercache/internal/cluster" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -39,6 +39,9 @@ func TestHintedHandoffReplay(t *testing.T) { p := any(primary).(*backend.DistMemory) r := any(replica).(*backend.DistMemory) + StopOnCleanup(t, p) + StopOnCleanup(t, r) + p.SetTransport(transport) // r transport deliberately not registered yet (simulate down replica) transport.Register(p) diff --git a/tests/hypercache_distmemory_integration_test.go b/tests/hypercache_distmemory_integration_test.go index e2bf467..32509f6 100644 --- a/tests/hypercache_distmemory_integration_test.go +++ b/tests/hypercache_distmemory_integration_test.go @@ -39,8 +39,18 @@ func TestDistMemoryForwardingReplication(t *testing.T) { t.Fatalf("backend2: %v", err) } - b1 := b1i.(*backend.DistMemory) //nolint:forcetypeassert - b2 := b2i.(*backend.DistMemory) //nolint:forcetypeassert + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) transport.Register(b1) transport.Register(b2) diff --git a/tests/hypercache_distmemory_remove_readrepair_test.go b/tests/hypercache_distmemory_remove_readrepair_test.go index 455bd18..f0d2324 100644 --- a/tests/hypercache_distmemory_remove_readrepair_test.go +++ b/tests/hypercache_distmemory_remove_readrepair_test.go @@ -10,7 +10,9 @@ import ( ) // helper to build two-node replicated cluster. -func newTwoNodeCluster(t *testing.T) (*backend.DistMemory, *backend.DistMemory, *cluster.Ring) { //nolint:thelper +func newTwoNodeCluster(t *testing.T) (*backend.DistMemory, *backend.DistMemory, *cluster.Ring) { + t.Helper() + ring := cluster.NewRing(cluster.WithReplication(2)) membership := cluster.NewMembership(ring) transport := backend.NewInProcessTransport() @@ -27,8 +29,18 @@ func newTwoNodeCluster(t *testing.T) (*backend.DistMemory, *backend.DistMemory, t.Fatalf("b2: %v", err) } - b1 := b1i.(*backend.DistMemory) //nolint:forcetypeassert - b2 := b2i.(*backend.DistMemory) //nolint:forcetypeassert + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) transport.Register(b1) transport.Register(b2) diff --git a/tests/hypercache_distmemory_stale_quorum_test.go b/tests/hypercache_distmemory_stale_quorum_test.go index 60c8a81..4db4b8a 100644 --- a/tests/hypercache_distmemory_stale_quorum_test.go +++ b/tests/hypercache_distmemory_stale_quorum_test.go @@ -39,9 +39,24 @@ func TestDistMemoryStaleQuorum(t *testing.T) { backend.WithDistReadConsistency(backend.ConsistencyQuorum), ) - b1 := b1i.(*backend.DistMemory) //nolint:forcetypeassert - b2 := b2i.(*backend.DistMemory) //nolint:forcetypeassert - b3 := b3i.(*backend.DistMemory) //nolint:forcetypeassert + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) transport.Register(b1) transport.Register(b2) diff --git a/tests/hypercache_distmemory_tiebreak_test.go b/tests/hypercache_distmemory_tiebreak_test.go index f29837d..6970595 100644 --- a/tests/hypercache_distmemory_tiebreak_test.go +++ b/tests/hypercache_distmemory_tiebreak_test.go @@ -39,9 +39,24 @@ func TestDistMemoryVersionTieBreak(t *testing.T) { //nolint:paralleltest backend.WithDistReadConsistency(backend.ConsistencyQuorum), ) - b1 := b1i.(*backend.DistMemory) //nolint:forcetypeassert - b2 := b2i.(*backend.DistMemory) //nolint:forcetypeassert - b3 := b3i.(*backend.DistMemory) //nolint:forcetypeassert + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) transport.Register(b1) transport.Register(b2) diff --git a/tests/hypercache_distmemory_versioning_test.go b/tests/hypercache_distmemory_versioning_test.go index 07ee70d..44227f4 100644 --- a/tests/hypercache_distmemory_versioning_test.go +++ b/tests/hypercache_distmemory_versioning_test.go @@ -42,9 +42,25 @@ func TestDistMemoryVersioningQuorum(t *testing.T) { //nolint:paralleltest backend.WithDistTransport(transport), backend.WithDistReadConsistency(backend.ConsistencyQuorum), ) - b1 := b1i.(*backend.DistMemory) //nolint:forcetypeassert - b2 := b2i.(*backend.DistMemory) //nolint:forcetypeassert - b3 := b3i.(*backend.DistMemory) //nolint:forcetypeassert + b1, ok := b1i.(*backend.DistMemory) + + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) transport.Register(b1) transport.Register(b2) diff --git a/tests/hypercache_distmemory_write_quorum_test.go b/tests/hypercache_distmemory_write_quorum_test.go index e6cc5a6..5b1eb0d 100644 --- a/tests/hypercache_distmemory_write_quorum_test.go +++ b/tests/hypercache_distmemory_write_quorum_test.go @@ -9,7 +9,7 @@ import ( "github.com/hyp3rd/hypercache/internal/cluster" "github.com/hyp3rd/hypercache/internal/sentinel" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -28,9 +28,24 @@ func TestWriteQuorumSuccess(t *testing.T) { b, _ := backend.NewDistMemory(ctx, append(opts, backend.WithDistNode("B", "B"))...) c, _ := backend.NewDistMemory(ctx, append(opts, backend.WithDistNode("C", "C"))...) - da := any(a).(*backend.DistMemory) - db := any(b).(*backend.DistMemory) - dc := any(c).(*backend.DistMemory) + da, ok := any(a).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", a) + } + + db, ok := any(b).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", b) + } + + dc, ok := any(c).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", c) + } + + StopOnCleanup(t, da) + StopOnCleanup(t, db) + StopOnCleanup(t, dc) da.SetTransport(transport) db.SetTransport(transport) @@ -84,12 +99,28 @@ func TestWriteQuorumFailure(t *testing.T) { ctx, append(opts, backend.WithDistNode("B", "B"), backend.WithDistMembership(m, cluster.NewNode("B", "B")))...) - _, _ = backend.NewDistMemory( + nc, _ := backend.NewDistMemory( ctx, append(opts, backend.WithDistNode("C", "C"), backend.WithDistMembership(m, cluster.NewNode("C", "C")))...) - da := any(na).(*backend.DistMemory) - db := any(nb).(*backend.DistMemory) + da, ok := any(na).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", na) + } + + db, ok := any(nb).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", nb) + } + + dc, ok := any(nc).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", nc) + } + + StopOnCleanup(t, da) + StopOnCleanup(t, db) + StopOnCleanup(t, dc) da.SetTransport(transport) db.SetTransport(transport) diff --git a/tests/hypercache_get_multiple_test.go b/tests/hypercache_get_multiple_test.go index ba761d0..f689f88 100644 --- a/tests/hypercache_get_multiple_test.go +++ b/tests/hypercache_get_multiple_test.go @@ -25,7 +25,7 @@ func TestGetMultiple(t *testing.T) { name: "get multiple keys with values", keys: []string{"key1", "key2", "key3"}, wantValues: map[string]any{"key1": 1, "key2": 2, "key3": 3}, - wantErrs: map[string]error(map[string]error{}), + wantErrs: map[string]error{}, setup: func(cache *hypercache.HyperCache[backend.InMemory]) { cache.Set(context.TODO(), "key1", 1, 0) cache.Set(context.TODO(), "key2", 2, 0) diff --git a/tests/hypercache_http_merkle_test.go b/tests/hypercache_http_merkle_test.go index 17e5f95..caeddb0 100644 --- a/tests/hypercache_http_merkle_test.go +++ b/tests/hypercache_http_merkle_test.go @@ -45,8 +45,18 @@ func TestHTTPFetchMerkle(t *testing.T) { t.Fatalf("b2: %v", err) } - b1 := b1i.(*backend.DistMemory) //nolint:forcetypeassert - b2 := b2i.(*backend.DistMemory) //nolint:forcetypeassert + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) // HTTP transport resolver maps node IDs to http base URLs. resolver := func(id string) (string, bool) { @@ -59,7 +69,9 @@ func TestHTTPFetchMerkle(t *testing.T) { return "", false } - transport := backend.NewDistHTTPTransport(2*time.Second, resolver) + // 5s transport timeout (was 2s) — under -race the fiber listener can take + // >2s to accept its first request, which made SyncWith time out spuriously. + transport := backend.NewDistHTTPTransport(5*time.Second, resolver) b1.SetTransport(transport) b2.SetTransport(transport) @@ -70,15 +82,29 @@ func TestHTTPFetchMerkle(t *testing.T) { b1.DebugInject(item) } - // ensure HTTP merkle endpoint reachable - resp, err := http.Get("http://" + b1.LocalNodeAddr() + "/internal/merkle") - if err != nil { - t.Fatalf("merkle http get: %v", err) + // Poll the HTTP merkle endpoint until it actually responds 200. Under + // -race the fiber listener can take seconds to start accepting requests + // even after Listen() returns; a single-shot Get is racy. + merkleReady := false + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get("http://" + b1.LocalNodeAddr() + "/internal/merkle") + if err == nil { + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + merkleReady = true + + break + } + } + + time.Sleep(50 * time.Millisecond) } - _ = resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("unexpected status %d", resp.StatusCode) + if !merkleReady { + t.Fatal("merkle endpoint did not become ready within deadline") } // b2 sync from b1 via HTTP transport @@ -86,8 +112,19 @@ func TestHTTPFetchMerkle(t *testing.T) { t.Fatalf("sync: %v", err) } - // validate keys present on b2 + // Validate keys present on b2. Allow brief retry to absorb any async tail + // in sync's apply path (each missing key is retried once). for i := range 5 { + if _, ok := b2.Get(ctx, httpKey(i)); ok { + continue + } + + // One retry: re-sync and check again. + err := b2.SyncWith(ctx, "n1") + if err != nil { + t.Fatalf("re-sync: %v", err) + } + if _, ok := b2.Get(ctx, httpKey(i)); !ok { t.Fatalf("missing key %d post-sync", i) } diff --git a/tests/hypercache_mgmt_dist_test.go b/tests/hypercache_mgmt_dist_test.go index 5739013..11d7c8c 100644 --- a/tests/hypercache_mgmt_dist_test.go +++ b/tests/hypercache_mgmt_dist_test.go @@ -16,7 +16,10 @@ import ( // TestManagementHTTPDistMemory validates management endpoints for the experimental DistMemory backend. func TestManagementHTTPDistMemory(t *testing.T) { //nolint:paralleltest - cfg := hypercache.NewConfig[backend.DistMemory](constants.DistMemoryBackend) + cfg, err := hypercache.NewConfig[backend.DistMemory](constants.DistMemoryBackend) + if err != nil { + t.Fatalf("NewConfig: %v", err) + } cfg.HyperCacheOptions = append(cfg.HyperCacheOptions, hypercache.WithManagementHTTP[backend.DistMemory]("127.0.0.1:0"), // ephemeral port @@ -97,7 +100,9 @@ func TestManagementHTTPDistMemory(t *testing.T) { //nolint:paralleltest } // waitForMgmt waits until management HTTP server is bound and responsive. -func waitForMgmt(t *testing.T, hc *hypercache.HyperCache[backend.DistMemory]) string { //nolint:thelper +func waitForMgmt(t *testing.T, hc *hypercache.HyperCache[backend.DistMemory]) string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) var addr string @@ -127,7 +132,9 @@ func waitForMgmt(t *testing.T, hc *hypercache.HyperCache[backend.DistMemory]) st return "http://" + addr } -func getJSON(t *testing.T, url string) map[string]any { //nolint:thelper +func getJSON(t *testing.T, url string) map[string]any { + t.Helper() + resp, err := http.Get(url) //nolint:noctx,gosec if err != nil { t.Fatalf("GET %s: %v", url, err) diff --git a/tests/hypercache_trigger_eviction_test.go b/tests/hypercache_trigger_eviction_test.go index b353f5c..f975502 100644 --- a/tests/hypercache_trigger_eviction_test.go +++ b/tests/hypercache_trigger_eviction_test.go @@ -31,9 +31,18 @@ func TestHyperCache_TriggerEviction_Immediate(t *testing.T) { hc.TriggerEviction(context.TODO()) } - // Allow a tiny time for worker to process - time.Sleep(50 * time.Millisecond) + // Eventually item count should be <= capacity (1). Poll instead of a + // fixed sleep — under -race the eviction pipeline (channel send -> + // expiration goroutine -> worker pool -> algorithm.Evict + Remove) + // can take well over 50 ms. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if hc.Count(context.TODO()) <= 1 { + break + } + + time.Sleep(20 * time.Millisecond) + } - // Eventually item count should be <= capacity (1) assert.True(t, hc.Count(context.TODO()) <= 1) } diff --git a/tests/integration/dist_phase1_test.go b/tests/integration/dist_phase1_test.go index c43e0ff..2583387 100644 --- a/tests/integration/dist_phase1_test.go +++ b/tests/integration/dist_phase1_test.go @@ -9,12 +9,14 @@ import ( "testing" "time" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) // allocatePort listens on :0 then closes to get a free port. func allocatePort(tb testing.TB) string { + tb.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { tb.Fatalf("listen: %v", err) @@ -51,7 +53,12 @@ func TestDistPhase1BasicQuorum(t *testing.T) { t.Fatalf("new dist memory: %v", err) } - return bm.(*backend.DistMemory) + bk, ok := bm.(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", bm) + } + + return bk } nodeA := makeNode("A", addrA, []string{addrB, addrC}) @@ -175,6 +182,8 @@ func valueOK(v any) bool { //nolint:ireturn } func assertValue(t *testing.T, v any) { //nolint:ireturn + t.Helper() + if !valueOK(v) { t.Fatalf("unexpected value representation: %T %v", v, v) } diff --git a/tests/integration/dist_rebalance_leave_test.go b/tests/integration/dist_rebalance_leave_test.go index 40e14b9..8d2e048 100644 --- a/tests/integration/dist_rebalance_leave_test.go +++ b/tests/integration/dist_rebalance_leave_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -24,10 +24,10 @@ func TestDistRebalanceLeave(t *testing.T) { backend.WithDistRebalanceInterval(100 * time.Millisecond), } - nodeA := mustDistNode(t, ctx, "A", addrA, []string{addrB, addrC}, opts...) - nodeB := mustDistNode(t, ctx, "B", addrB, []string{addrA, addrC}, opts...) + nodeA := mustDistNode(ctx, t, "A", addrA, []string{addrB, addrC}, opts...) + nodeB := mustDistNode(ctx, t, "B", addrB, []string{addrA, addrC}, opts...) - nodeC := mustDistNode(t, ctx, "C", addrC, []string{addrA, addrB}, opts...) + nodeC := mustDistNode(ctx, t, "C", addrC, []string{addrA, addrB}, opts...) defer func() { _ = nodeA.Stop(ctx); _ = nodeB.Stop(ctx); _ = nodeC.Stop(ctx) }() // Insert keys through A. diff --git a/tests/integration/dist_rebalance_replica_diff_test.go b/tests/integration/dist_rebalance_replica_diff_test.go index 654d675..4db58e8 100644 --- a/tests/integration/dist_rebalance_replica_diff_test.go +++ b/tests/integration/dist_rebalance_replica_diff_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -24,9 +24,9 @@ func TestDistRebalanceReplicaDiff(t *testing.T) { backend.WithDistRebalanceInterval(120 * time.Millisecond), } - nodeA := mustDistNode(t, ctx, "A", addrA, []string{addrB}, baseOpts...) + nodeA := mustDistNode(ctx, t, "A", addrA, []string{addrB}, baseOpts...) - nodeB := mustDistNode(t, ctx, "B", addrB, []string{addrA}, baseOpts...) + nodeB := mustDistNode(ctx, t, "B", addrB, []string{addrA}, baseOpts...) defer func() { _ = nodeA.Stop(ctx); _ = nodeB.Stop(ctx) }() // Insert a set of keys through primary (either node). We'll use A. @@ -49,7 +49,7 @@ func TestDistRebalanceReplicaDiff(t *testing.T) { // as a replica for some keys (virtual nodes distribution will produce owners including C) by simply adding the peer. addrC := allocatePort(t) - nodeC := mustDistNode(t, ctx, "C", addrC, []string{addrA, addrB}, append(baseOpts, backend.WithDistReplication(3))...) + nodeC := mustDistNode(ctx, t, "C", addrC, []string{addrA, addrB}, append(baseOpts, backend.WithDistReplication(3))...) defer func() { _ = nodeC.Stop(ctx) }() // Propagate C to existing nodes (they still have replication=2 configured, but ring will include C; diff --git a/tests/integration/dist_rebalance_replica_diff_throttle_test.go b/tests/integration/dist_rebalance_replica_diff_throttle_test.go index c28144e..30f1cf6 100644 --- a/tests/integration/dist_rebalance_replica_diff_throttle_test.go +++ b/tests/integration/dist_rebalance_replica_diff_throttle_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -24,9 +24,9 @@ func TestDistRebalanceReplicaDiffThrottle(t *testing.T) { backend.WithDistReplicaDiffMaxPerTick(1), } - nodeA := mustDistNode(t, ctx, "A", addrA, []string{addrB}, base...) + nodeA := mustDistNode(ctx, t, "A", addrA, []string{addrB}, base...) - nodeB := mustDistNode(t, ctx, "B", addrB, []string{addrA}, base...) + nodeB := mustDistNode(ctx, t, "B", addrB, []string{addrA}, base...) defer func() { _ = nodeA.Stop(ctx); _ = nodeB.Stop(ctx) }() // Seed multiple keys. @@ -41,7 +41,7 @@ func TestDistRebalanceReplicaDiffThrottle(t *testing.T) { // Add third node with replication=3 so it becomes new replica for many keys. addrC := allocatePort(t) - nodeC := mustDistNode(t, ctx, "C", addrC, []string{addrA, addrB}, append(base, backend.WithDistReplication(3))...) + nodeC := mustDistNode(ctx, t, "C", addrC, []string{addrA, addrB}, append(base, backend.WithDistReplication(3))...) defer func() { _ = nodeC.Stop(ctx) }() nodeA.AddPeer(addrC) diff --git a/tests/integration/dist_rebalance_test.go b/tests/integration/dist_rebalance_test.go index d7326c1..6dd4846 100644 --- a/tests/integration/dist_rebalance_test.go +++ b/tests/integration/dist_rebalance_test.go @@ -9,8 +9,7 @@ import ( "testing" "time" - "github.com/hyp3rd/hypercache/internal/cluster" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -23,8 +22,8 @@ func TestDistRebalanceJoin(t *testing.T) { addrB := allocatePort(t) nodeA := mustDistNode( - t, ctx, + t, "A", addrA, []string{addrB}, @@ -34,8 +33,8 @@ func TestDistRebalanceJoin(t *testing.T) { ) nodeB := mustDistNode( - t, ctx, + t, "B", addrB, []string{addrA}, @@ -70,8 +69,8 @@ func TestDistRebalanceJoin(t *testing.T) { addrC := allocatePort(t) nodeC := mustDistNode( - t, ctx, + t, "C", addrC, []string{addrA, addrB}, @@ -127,9 +126,9 @@ func TestDistRebalanceThrottle(t *testing.T) { backend.WithDistRebalanceMaxConcurrent(1), } - nodeA := mustDistNode(t, ctx, "A", addrA, []string{addrB}, opts...) + nodeA := mustDistNode(ctx, t, "A", addrA, []string{addrB}, opts...) + nodeB := mustDistNode(ctx, t, "B", addrB, []string{addrA}, opts...) - nodeB := mustDistNode(t, ctx, "B", addrB, []string{addrA}, opts...) defer func() { _ = nodeA.Stop(ctx); _ = nodeB.Stop(ctx) }() // Populate many keys on A. @@ -147,7 +146,7 @@ func TestDistRebalanceThrottle(t *testing.T) { // Add third node to force migrations while concurrency=1, which should queue batches. addrC := allocatePort(t) - nodeC := mustDistNode(t, ctx, "C", addrC, []string{addrA, addrB}, opts...) + nodeC := mustDistNode(ctx, t, "C", addrC, []string{addrA, addrB}, opts...) defer func() { _ = nodeC.Stop(ctx) }() // propagate membership like in join test @@ -167,12 +166,14 @@ func TestDistRebalanceThrottle(t *testing.T) { // Helpers. func mustDistNode( - t *testing.T, ctx context.Context, + t *testing.T, id, addr string, seeds []string, extra ...backend.DistMemoryOption, ) *backend.DistMemory { + t.Helper() + opts := []backend.DistMemoryOption{ backend.WithDistNode(id, addr), backend.WithDistSeeds(seeds), @@ -191,7 +192,12 @@ func mustDistNode( waitForDistNodeHealth(t, addr) - return bm.(*backend.DistMemory) + bk, ok := bm.(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", bm) + } + + return bk } func cacheKey(i int) string { return "k" + strconv.Itoa(i) } @@ -217,7 +223,7 @@ func ownedPrimaryCount(dm *backend.DistMemory, keys []string) int { c := 0 - self := cluster.NodeID(dm.LocalNodeID()) + self := dm.LocalNodeID() for _, k := range keys { owners := ring.Lookup(k) if len(owners) > 0 && owners[0] == self { diff --git a/tests/management_http_test.go b/tests/management_http_test.go index 18e8fff..2181cd0 100644 --- a/tests/management_http_test.go +++ b/tests/management_http_test.go @@ -17,7 +17,10 @@ import ( // TestManagementHTTP_BasicEndpoints spins up the management HTTP server on an ephemeral port // and validates core endpoints. func TestManagementHTTP_BasicEndpoints(t *testing.T) { - cfg := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + cfg, err := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + if err != nil { + t.Fatalf("NewConfig: %v", err) + } cfg.HyperCacheOptions = append(cfg.HyperCacheOptions, hypercache.WithEvictionInterval[backend.InMemory](0), @@ -30,24 +33,43 @@ func TestManagementHTTP_BasicEndpoints(t *testing.T) { defer hc.Stop(ctx) - // wait briefly for listener - time.Sleep(30 * time.Millisecond) + // Wait for the management HTTP listener to come up. The race detector + // can push listener startup well past the original 30 ms; poll with a + // generous deadline instead. + var addr string - addr := hc.ManagementHTTPAddress() - assert.True(t, addr != "") + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + addr = hc.ManagementHTTPAddress() + if addr != "" { + break + } - client := &http.Client{Timeout: 2 * time.Second} + time.Sleep(10 * time.Millisecond) + } + + if addr == "" { + t.Fatal("management HTTP listener did not bind within deadline") + } + + client := &http.Client{Timeout: 5 * time.Second} // /health resp, err := client.Get("http://" + addr + "/health") - assert.Nil(t, err) + if err != nil { + t.Fatalf("GET /health: %v", err) + } + assert.Equal(t, http.StatusOK, resp.StatusCode) _ = resp.Body.Close() // /stats resp, err = client.Get("http://" + addr + "/stats") - assert.Nil(t, err) + if err != nil { + t.Fatalf("GET /stats: %v", err) + } + assert.Equal(t, http.StatusOK, resp.StatusCode) var statsBody map[string]any @@ -61,7 +83,10 @@ func TestManagementHTTP_BasicEndpoints(t *testing.T) { // /config resp, err = client.Get("http://" + addr + "/config") - assert.Nil(t, err) + if err != nil { + t.Fatalf("GET /config: %v", err) + } + assert.Equal(t, http.StatusOK, resp.StatusCode) var cfgBody map[string]any diff --git a/tests/merkle_delete_tombstone_test.go b/tests/merkle_delete_tombstone_test.go index 4a18015..1923c0f 100644 --- a/tests/merkle_delete_tombstone_test.go +++ b/tests/merkle_delete_tombstone_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -27,8 +27,18 @@ func TestMerkleDeleteTombstone(t *testing.T) { backend.WithDistMerkleChunkSize(2), ) - da := any(a).(*backend.DistMemory) - db := any(b).(*backend.DistMemory) + da, ok := any(a).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast a to *backend.DistMemory") + } + + db, ok := any(b).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b to *backend.DistMemory") + } + + StopOnCleanup(t, da) + StopOnCleanup(t, db) da.SetTransport(transport) db.SetTransport(transport) diff --git a/tests/merkle_empty_tree_test.go b/tests/merkle_empty_tree_test.go index d8efa96..796e11d 100644 --- a/tests/merkle_empty_tree_test.go +++ b/tests/merkle_empty_tree_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" ) // TestMerkleEmptyTrees ensures diff between two empty trees is empty and SyncWith is no-op. @@ -25,8 +25,18 @@ func TestMerkleEmptyTrees(t *testing.T) { backend.WithDistMerkleChunkSize(2), ) - da := any(a).(*backend.DistMemory) - db := any(b).(*backend.DistMemory) + da, ok := any(a).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast a to *backend.DistMemory") + } + + db, ok := any(b).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b to *backend.DistMemory") + } + + StopOnCleanup(t, da) + StopOnCleanup(t, db) da.SetTransport(transport) db.SetTransport(transport) diff --git a/tests/merkle_no_diff_test.go b/tests/merkle_no_diff_test.go index 207c414..fd03e96 100644 --- a/tests/merkle_no_diff_test.go +++ b/tests/merkle_no_diff_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -27,8 +27,18 @@ func TestMerkleNoDiff(t *testing.T) { backend.WithDistMerkleChunkSize(4), ) - da := any(a).(*backend.DistMemory) - db := any(b).(*backend.DistMemory) + da, ok := any(a).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast a to *backend.DistMemory") + } + + db, ok := any(b).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b to *backend.DistMemory") + } + + StopOnCleanup(t, da) + StopOnCleanup(t, db) da.SetTransport(transport) db.SetTransport(transport) diff --git a/tests/merkle_single_missing_key_test.go b/tests/merkle_single_missing_key_test.go index 485c956..6175430 100644 --- a/tests/merkle_single_missing_key_test.go +++ b/tests/merkle_single_missing_key_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -27,8 +27,18 @@ func TestMerkleSingleMissingKey(t *testing.T) { backend.WithDistMerkleChunkSize(2), ) - da := any(a).(*backend.DistMemory) - db := any(b).(*backend.DistMemory) + da, ok := any(a).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast a to *backend.DistMemory") + } + + db, ok := any(b).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b to *backend.DistMemory") + } + + StopOnCleanup(t, da) + StopOnCleanup(t, db) da.SetTransport(transport) db.SetTransport(transport) diff --git a/tests/merkle_sync_test.go b/tests/merkle_sync_test.go index 208d25c..13ae5e4 100644 --- a/tests/merkle_sync_test.go +++ b/tests/merkle_sync_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - backend "github.com/hyp3rd/hypercache/pkg/backend" + "github.com/hyp3rd/hypercache/pkg/backend" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) @@ -23,7 +23,12 @@ func TestMerkleSyncConvergence(t *testing.T) { t.Fatalf("new dist memory A: %v", err) } - dmA := any(bA).(*backend.DistMemory) + dmA, ok := any(bA).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", bA) + } + + StopOnCleanup(t, dmA) bB, err := backend.NewDistMemory(ctx, backend.WithDistNode("B", AllocatePort(t)), @@ -34,7 +39,12 @@ func TestMerkleSyncConvergence(t *testing.T) { t.Fatalf("new dist memory B: %v", err) } - dmB := any(bB).(*backend.DistMemory) + dmB, ok := any(bB).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", bB) + } + + StopOnCleanup(t, dmB) dmA.SetTransport(transport) dmB.SetTransport(transport) diff --git a/tests/port_helper.go b/tests/port_helper.go index 04cc4b8..4b9b4a8 100644 --- a/tests/port_helper.go +++ b/tests/port_helper.go @@ -4,8 +4,28 @@ import ( "context" "net" "testing" + + "github.com/hyp3rd/hypercache/pkg/backend" ) +// StopOnCleanup registers Stop on t.Cleanup so background goroutines +// (heartbeat, hint-replay, rebalance, autosync, tombstone, gossip) and HTTP +// listeners do not leak across test iterations under -count=N -race. +// +// nil-tolerant: if the backend creation failed, this is a no-op so callers +// can keep the existing pattern of `b, _ := backend.NewDistMemory(...)`. +func StopOnCleanup(tb testing.TB, b *backend.DistMemory) { + tb.Helper() + + if b == nil { + return + } + + tb.Cleanup(func() { + _ = b.Stop(context.Background()) + }) +} + // AllocatePort returns a free TCP loopback address ("127.0.0.1:N") for tests // that need to bind a server. Listening on :0 lets the kernel pick an unused // port; we close immediately and return the address. Two tests calling this From a47775b8d1bd2e10decd6db4b8cddbc0a7c134d7 Mon Sep 17 00:00:00 2001 From: "F." Date: Sun, 3 May 2026 14:52:42 +0200 Subject: [PATCH 6/8] feat(eviction): introduce sharded eviction algorithm to reduce lock contention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `evictionShardCount` field to `HyperCache` and a corresponding `WithEvictionShardCount` option (default: 32, matching pkg/cache/v2.ShardCount). When `evictionShardCount > 1`, `initEvictionAlgorithm` wraps the selected algorithm in `eviction.Sharded`, aligning each key's data shard with its eviction shard to eliminate the single global eviction-algorithm mutex. This trades strict global LRU/LFU ordering for significantly improved parallel-write throughput (~4x on Apple M4 Pro: SetParallel drops from ~750 ns/op to ~200 ns/op). Values ≤ 1 preserve the previous single-instance behaviour with strict ordering. Additional changes: - Replace `errors.New` with `ewrap.New` for ErrInvalidAddress in cluster/node.go - Add benchmark result snapshots: bench-baseline-v2, bench-step1, bench-step1-unit, bench-step2 - Add lint baseline snapshot: lint-baseline-v2 --- bench-baseline-v2.txt | 51 + bench-baseline.txt | Bin 5149 -> 5151 bytes bench-step1-unit.txt | 26 + bench-step1.txt | 51 + bench-step2.txt | 51 + config.go | 14 + hypercache.go | 28 +- internal/cluster/node.go | 4 +- lint-baseline-v2.txt | 4138 +++++++++++++++++ pkg/backend/dist_memory.go | 6 +- pkg/cache/v2/cmap.go | 54 +- pkg/cache/v2/cmap_test.go | 38 + pkg/cache/v2/hash.go | 26 + pkg/eviction/sharded.go | 104 + pkg/eviction/sharded_test.go | 205 + pool_test.go | 4 +- race-baseline-v2.log | 21 + race-step2.log | 21 + ...ache_distmemory_heartbeat_sampling_test.go | 6 +- 19 files changed, 4813 insertions(+), 35 deletions(-) create mode 100644 bench-baseline-v2.txt create mode 100644 bench-step1-unit.txt create mode 100644 bench-step1.txt create mode 100644 bench-step2.txt create mode 100644 lint-baseline-v2.txt create mode 100644 pkg/cache/v2/hash.go create mode 100644 pkg/eviction/sharded.go create mode 100644 pkg/eviction/sharded_test.go create mode 100644 race-baseline-v2.log create mode 100644 race-step2.log diff --git a/bench-baseline-v2.txt b/bench-baseline-v2.txt new file mode 100644 index 0000000..b3f5ef9 --- /dev/null +++ b/bench-baseline-v2.txt @@ -0,0 +1,51 @@ +goos: darwin +goarch: arm64 +pkg: github.com/hyp3rd/hypercache/tests/benchmark +cpu: Apple M4 Pro +BenchmarkHyperCache_SetParallel-14 6676305 711.9 ns/op 218 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6438765 729.0 ns/op 220 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6961741 807.6 ns/op 218 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6262878 710.7 ns/op 223 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6871442 789.8 ns/op 217 B/op 3 allocs/op +BenchmarkHyperCache_GetParallel-14 54231619 88.86 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 53125920 89.61 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 53083129 89.39 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 54015073 88.35 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 54414236 91.13 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6655920 748.5 ns/op 218 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6446132 738.8 ns/op 220 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6796017 777.4 ns/op 217 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6508280 712.7 ns/op 220 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6980356 783.1 ns/op 219 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 33034370 167.5 ns/op 157 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 29370020 165.6 ns/op 159 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 32026545 180.1 ns/op 158 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 28606334 168.0 ns/op 160 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 31824206 178.5 ns/op 158 B/op 3 allocs/op +BenchmarkHyperCache_Get-14 40344086 110.3 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 43160210 113.0 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 43770510 112.1 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 43183962 114.3 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 43036879 111.4 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 42927090 112.1 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 43338003 114.9 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 40403430 113.5 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 42223924 116.6 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 41546704 117.6 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 46045465 105.6 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 45772118 101.4 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 47736949 101.0 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 46948050 99.45 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 46891046 99.97 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Set-14 12900670 469.6 ns/op 222 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 8785929 479.5 ns/op 255 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 12034856 488.7 ns/op 227 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 10504384 520.1 ns/op 238 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 11103819 486.2 ns/op 233 B/op 3 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 4011253 1260 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3742882 1231 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 4316617 1150 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 4342858 1120 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3557096 1353 ns/op 454 B/op 6 allocs/op +PASS +ok github.com/hyp3rd/hypercache/tests/benchmark 259.760s diff --git a/bench-baseline.txt b/bench-baseline.txt index 878e8d549a749f3a8eb8638598472c47f8ebb03f..b3f5ef9f77d85544a716323e5496580a7e68e276 100644 GIT binary patch literal 5151 zcmcJT+iu%N5QeX*r`Q+hdQN-tT~ZXuMS~dd0~m@-Sb`4}C?##*J|i?dEDMv^n$`^= z>hNJ^Xa0YN4^4BrHuu~1ufxmcq1m>({k7S)&t5ji$A@e4a5(S3f3v&h`D*{qk=y&r zrEYiI-M+p$*QfL8>RbJ?+dpsH$Ib5e{n~su9-nISMa)gxY(93MeZKta_sg&DZtL^S zw%tBG)la{nnEX{4?}KM>)vx;j(I)e9x@wO9-Xe_VWB<+=Hu_i1?(&8Ig%gg!4>(E| zG91CGxa2VicF{!$)@Qhtqa#l-#10n_Y{+oLt40?CicpV>$;J$ap$PYBwp5%DqsP?Y zOpG>W*+h03j}~fWfyYQL5e&R!J&xmBn+H*mqxh2LtQS`137~_(LwwO{0!O{KHcwEG z(D%HA7Ws|5ukCC4a*-A{KEEK*X5p1ei7W6K+1$3)MM?l_5Z+aLZPbRuDk6nl^dhpEBRVMnFdo230Qklmw zMiPR)R4`21yfiwk%>NE5-PlrzR*FCzaO$zSh*6$QTsEOOoLU|b4wZ>KN#@G95WtOb zG%1ypaTq!IUNaHpeUl07W9S_gb=b@_6U(E!)46xMJ0E`3fBZP;wR!1Ivl2=aAdSxM z?a7?OF>?gy{rr;5e0*3pI=Hj~kD}v=X0D4PB2F}@3i^p=u8Tt_KEMb!ncPcD<P7Xl&nivq$M8({$#0{bLG-zhHJy4Fr+k_fJ^Op}t8Z1P*1 z7ZZZ?mT=*vZ0{=N=}glQ^(&3Oxm*e=P>OLJE6#)o}ENyirXlb)< z!r<_5WLCy_1fIcnJSjPA=iEGY=p?Oz2~^4%>tipL2-EQ$cyQMGTp+7y_e|i@Vt-y< z(a&$qOZrk6iPK`eZO_l^U>hjX`_jVW<8lnA7M$~GT)fAU`_+-PbK*oD$ARgp!+~&y zTsS9ICxM;zaL$*|uMR`L>r-*!ERajpu@0-tg>ww|b@RKreaVLkL$WiC9vDz}RzZ`FAwFy7wCWab`Y$9GKqc+n z0hhVZsm+Y8@I0urv8CYX=Nv+EAs`r>9s2C-U|dy>;7G}NkR)Pf$HiufPl4rt%bPiG zbq*t;Qxzc0RR!k735t539Pfb3(%&m^H!=4vSNqM+>f_H%eAZ9xX%@sRB(`^Ud{&$y zXcU%K#DHvR`w~5KbrFYzY43oj)HC9u*{OqGGh;eBBTgua3ij4~Vl%&Nb}!3H92GA< z*p!W85}KweV>k#1U2=zb>&hMB#JwBC#akZO-lzZV(Xb(4W@5XTCnLEFm zEYUI1wi?pfPWLQ~XBRJd$zG$N4K8_-8_-yI7{ihEfzxCi{NA! zYc-FxQ*lvNpmTF)B^NQ2{EjdgXUR&EXlLVI4DP95F;(OTL*%f8_W$u8^2j;13QRhUNAc>-ran=aZxwVT3L+G7`isv cETJ5$?CRb1^`gEvra0wmyaH8A0JmHG1w9fOegFUf diff --git a/bench-step1-unit.txt b/bench-step1-unit.txt new file mode 100644 index 0000000..1133e22 --- /dev/null +++ b/bench-step1-unit.txt @@ -0,0 +1,26 @@ +goos: darwin +goarch: arm64 +pkg: github.com/hyp3rd/hypercache/pkg/cache/v2 +cpu: Apple M4 Pro +BenchmarkConcurrentMap_Count-14 243351127 9.783 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_Count-14 248693774 9.691 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_Count-14 245917975 9.753 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_Count-14 246347533 9.857 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_Count-14 242966653 9.768 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_CountParallel-14 187792453 13.33 ns/op 8 B/op 0 allocs/op +BenchmarkConcurrentMap_CountParallel-14 175128324 13.76 ns/op 8 B/op 0 allocs/op +BenchmarkConcurrentMap_CountParallel-14 173046586 14.09 ns/op 8 B/op 0 allocs/op +BenchmarkConcurrentMap_CountParallel-14 174620641 13.80 ns/op 8 B/op 0 allocs/op +BenchmarkConcurrentMap_CountParallel-14 174253932 14.02 ns/op 8 B/op 0 allocs/op +BenchmarkConcurrentMap_GetShard-14 232021467 10.15 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_GetShard-14 238652191 10.10 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_GetShard-14 236221518 10.16 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_GetShard-14 237259972 10.12 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_GetShard-14 238689426 10.03 ns/op 0 B/op 0 allocs/op +BenchmarkConcurrentMap_IterBuffered-14 2704 778859 ns/op 1811549 B/op 230 allocs/op +BenchmarkConcurrentMap_IterBuffered-14 3111 771239 ns/op 1811412 B/op 230 allocs/op +BenchmarkConcurrentMap_IterBuffered-14 3247 756859 ns/op 1811416 B/op 230 allocs/op +BenchmarkConcurrentMap_IterBuffered-14 3224 757272 ns/op 1811418 B/op 230 allocs/op +BenchmarkConcurrentMap_IterBuffered-14 3092 756387 ns/op 1811415 B/op 230 allocs/op +PASS +ok github.com/hyp3rd/hypercache/pkg/cache/v2 48.545s diff --git a/bench-step1.txt b/bench-step1.txt new file mode 100644 index 0000000..8718918 --- /dev/null +++ b/bench-step1.txt @@ -0,0 +1,51 @@ +goos: darwin +goarch: arm64 +pkg: github.com/hyp3rd/hypercache/tests/benchmark +cpu: Apple M4 Pro +BenchmarkHyperCache_SetParallel-14 6260472 774.9 ns/op 223 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6864753 767.0 ns/op 217 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6478573 765.3 ns/op 220 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6912163 758.1 ns/op 217 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 6514670 714.9 ns/op 220 B/op 3 allocs/op +BenchmarkHyperCache_GetParallel-14 56692104 85.48 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 56765685 84.98 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 55099080 84.60 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 57246303 84.74 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 55435438 80.97 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6500679 761.3 ns/op 220 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6420355 761.2 ns/op 221 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6384942 758.3 ns/op 221 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6635151 768.5 ns/op 218 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 6613527 714.6 ns/op 218 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 30420403 170.4 ns/op 159 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 29663520 176.7 ns/op 159 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 29980153 175.3 ns/op 159 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 30013122 162.4 ns/op 159 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 32641153 168.0 ns/op 157 B/op 3 allocs/op +BenchmarkHyperCache_Get-14 42228490 108.1 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 44702448 107.7 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 44540242 108.1 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 44453173 107.7 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 44364632 108.5 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 44611564 108.4 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 43978932 109.5 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 43477210 110.8 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 43584688 111.4 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 42684261 112.2 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 44195536 109.3 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 45295766 104.8 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 45874153 104.8 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 45919684 103.9 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 45967378 104.4 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Set-14 12399925 449.5 ns/op 225 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 11446411 478.4 ns/op 231 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 12122114 446.2 ns/op 226 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 11246661 472.0 ns/op 232 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 11021144 478.0 ns/op 234 B/op 3 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 4333801 1182 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 4385100 1121 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 4257256 1134 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 4205035 1203 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 4128036 1115 ns/op 454 B/op 6 allocs/op +PASS +ok github.com/hyp3rd/hypercache/tests/benchmark 258.571s diff --git a/bench-step2.txt b/bench-step2.txt new file mode 100644 index 0000000..7346c9a --- /dev/null +++ b/bench-step2.txt @@ -0,0 +1,51 @@ +goos: darwin +goarch: arm64 +pkg: github.com/hyp3rd/hypercache/tests/benchmark +cpu: Apple M4 Pro +BenchmarkHyperCache_SetParallel-14 28227842 188.5 ns/op 221 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 32025574 229.3 ns/op 262 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 26087546 195.9 ns/op 219 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 31543452 206.6 ns/op 263 B/op 3 allocs/op +BenchmarkHyperCache_SetParallel-14 34179614 204.5 ns/op 255 B/op 3 allocs/op +BenchmarkHyperCache_GetParallel-14 56597265 82.91 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 57092503 87.24 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 55513336 82.22 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 55296619 83.76 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetParallel-14 56040259 82.45 ns/op 135 B/op 2 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 25826862 208.2 ns/op 220 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 28858351 214.8 ns/op 231 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 28640280 210.6 ns/op 227 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 30360506 222.7 ns/op 259 B/op 3 allocs/op +BenchmarkHyperCache_GetOrSetParallel-14 27698293 204.7 ns/op 217 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 47300134 121.5 ns/op 162 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 48800263 137.4 ns/op 162 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 47583622 122.8 ns/op 162 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 45183699 125.9 ns/op 163 B/op 3 allocs/op +BenchmarkHyperCache_MixedParallel-14 48629344 122.2 ns/op 162 B/op 3 allocs/op +BenchmarkHyperCache_Get-14 41079711 117.7 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 40609336 122.5 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 37839892 124.7 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 39746574 124.6 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get-14 36861888 124.0 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 39026796 123.4 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 38521831 124.2 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 38527873 126.3 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 38009490 126.0 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Get_ProactiveEviction-14 38018910 127.5 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 40412685 119.0 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 41405366 113.7 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 42876250 112.3 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 43379095 111.0 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_List-14 41403536 112.5 ns/op 128 B/op 1 allocs/op +BenchmarkHyperCache_Set-14 10691716 534.1 ns/op 236 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 9867717 561.2 ns/op 243 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 9506500 542.2 ns/op 247 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 8988578 550.0 ns/op 252 B/op 3 allocs/op +BenchmarkHyperCache_Set-14 8975970 558.5 ns/op 252 B/op 3 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3497715 1480 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3486507 1433 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3464900 1428 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3521899 1415 ns/op 454 B/op 6 allocs/op +BenchmarkHyperCache_Set_Proactive_Eviction-14 3444156 1450 ns/op 454 B/op 6 allocs/op +PASS +ok github.com/hyp3rd/hypercache/tests/benchmark 287.373s diff --git a/config.go b/config.go index 42edd07..dcaf1f8 100644 --- a/config.go +++ b/config.go @@ -107,6 +107,20 @@ func WithMaxCacheSize[T backend.IBackendConstrain](maxCacheSize int64) Option[T] } } +// WithEvictionShardCount sets the number of independent eviction-algorithm +// shards. Default 32 (matches pkg/cache/v2.ShardCount). Must be a positive +// power of two; values <= 1 disable sharding (single global eviction +// instance — strict global LRU/LFU order, but single-mutex contention). +// +// With sharding enabled, total capacity is split as ceil(capacity/n) per +// shard. Eviction order is per-shard, not strict global. See +// pkg/eviction.Sharded for full semantics. +func WithEvictionShardCount[T backend.IBackendConstrain](shardCount int) Option[T] { + return func(cache *HyperCache[T]) { + cache.evictionShardCount = shardCount + } +} + // WithEvictionAlgorithm is an option that sets the eviction algorithm name field of the `HyperCache` struct. // The eviction algorithm name determines which eviction algorithm will be used to evict items from the cache. // The eviction algorithm name must be one of the following: diff --git a/hypercache.go b/hypercache.go index 66b25d9..9617c00 100644 --- a/hypercache.go +++ b/hypercache.go @@ -55,6 +55,7 @@ type HyperCache[T backend.IBackendConstrain] struct { evictCh chan bool // manual eviction trigger evictionAlgorithmName string // name of eviction algorithm evictionAlgorithm eviction.IAlgorithm // eviction algorithm impl + evictionShardCount int // number of eviction-algo shards (default 32; <=1 disables sharding) expirationInterval time.Duration // interval for expiration loop evictionInterval time.Duration // interval for eviction loop shouldEvict atomic.Bool // proactive eviction enabled @@ -285,6 +286,10 @@ func newHyperCacheBase[T backend.IBackendConstrain](b backend.IBackend[T]) *Hype evictCh: make(chan bool, 1), expirationInterval: constants.DefaultExpirationInterval, evictionInterval: constants.DefaultEvictionInterval, + // Default eviction shard count matches pkg/cache/v2.ShardCount so a + // key's data shard and eviction shard map to the same logical position. + // Users can override with WithEvictionShardCount; <=1 disables sharding. + evictionShardCount: cache.ShardCount, } } @@ -305,20 +310,31 @@ func configureEvictionSettings[T backend.IBackendConstrain](hc *HyperCache[T]) { } // initEvictionAlgorithm initializes the eviction algorithm for the cache. +// +// When evictionShardCount > 1 (default 32) the algorithm is wrapped in +// eviction.Sharded — same hash as ConcurrentMap, so a key's data shard and +// eviction shard align. This eliminates the global eviction-algorithm mutex +// at the cost of strict global LRU/LFU ordering. shardCount <= 1 keeps the +// previous single-instance behavior. func initEvictionAlgorithm[T backend.IBackendConstrain](hc *HyperCache[T]) error { maxEvictionCount, err := converters.ToInt(hc.maxEvictionCount) if err != nil { return err } - if hc.evictionAlgorithmName == "" { - // Use the default eviction algorithm if none is specified - hc.evictionAlgorithm, err = eviction.NewLRUAlgorithm(maxEvictionCount) - } else { - // Use the specified eviction algorithm - hc.evictionAlgorithm, err = eviction.NewEvictionAlgorithm(hc.evictionAlgorithmName, maxEvictionCount) + algorithmName := hc.evictionAlgorithmName + if algorithmName == "" { + algorithmName = "lru" + } + + if hc.evictionShardCount > 1 { + hc.evictionAlgorithm, err = eviction.NewSharded(algorithmName, maxEvictionCount, hc.evictionShardCount) + + return err } + hc.evictionAlgorithm, err = eviction.NewEvictionAlgorithm(algorithmName, maxEvictionCount) + return err } diff --git a/internal/cluster/node.go b/internal/cluster/node.go index 5cd4bf8..6e33928 100644 --- a/internal/cluster/node.go +++ b/internal/cluster/node.go @@ -3,12 +3,12 @@ package cluster import ( "encoding/binary" "encoding/hex" - "errors" "fmt" "net" "time" "github.com/cespare/xxhash/v2" + "github.com/hyp3rd/ewrap" ) // NodeState represents membership state of a node. @@ -52,7 +52,7 @@ type Node struct { } // ErrInvalidAddress is returned when the node address is invalid. -var ErrInvalidAddress = errors.New("invalid node address") +var ErrInvalidAddress = ewrap.New("invalid node address") // NewNode creates a node from address (host:port). If id empty, derive a short hex id using xxhash64. func NewNode(id, addr string) *Node { diff --git a/lint-baseline-v2.txt b/lint-baseline-v2.txt new file mode 100644 index 0000000..da4c1b3 --- /dev/null +++ b/lint-baseline-v2.txt @@ -0,0 +1,4138 @@ +tests/hypercache_distmemory_heartbeat_test.go:14:1: calculated cyclomatic complexity for function TestDistMemoryHeartbeatLiveness is 27, max is 15 (cyclop) +func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest +^ +tests/hypercache_distmemory_integration_test.go:15:1: calculated cyclomatic complexity for function TestDistMemoryForwardingReplication is 16, max is 15 (cyclop) +func TestDistMemoryForwardingReplication(t *testing.T) { +^ +tests/hypercache_distmemory_remove_readrepair_test.go:113:1: calculated cyclomatic complexity for function TestDistMemoryReadRepair is 27, max is 15 (cyclop) +func TestDistMemoryReadRepair(t *testing.T) { +^ +tests/hypercache_distmemory_stale_quorum_test.go:14:1: calculated cyclomatic complexity for function TestDistMemoryStaleQuorum is 16, max is 15 (cyclop) +func TestDistMemoryStaleQuorum(t *testing.T) { +^ +tests/hypercache_distmemory_versioning_test.go:17:1: calculated cyclomatic complexity for function TestDistMemoryVersioningQuorum is 16, max is 15 (cyclop) +func TestDistMemoryVersioningQuorum(t *testing.T) { //nolint:paralleltest +^ +tests/hypercache_http_merkle_test.go:15:1: calculated cyclomatic complexity for function TestHTTPFetchMerkle is 17, max is 15 (cyclop) +func TestHTTPFetchMerkle(t *testing.T) { +^ +tests/integration/dist_phase1_test.go:115:1: calculated cyclomatic complexity for function valueOK is 25, max is 15 (cyclop) +func valueOK(v any) bool { //nolint:ireturn +^ +pkg/eviction/cawolfu_test.go:1: 1-91 lines are duplicate of `pkg/eviction/lru_test.go:1-92` (dupl) +package eviction + +import "testing" + +func TestCAWOLFU_EvictsLeastFrequentTail(t *testing.T) { + c, err := NewCAWOLFU(2) + if err != nil { + t.Fatalf("NewCAWOLFU error: %v", err) + } + + c.Set("a", 1) + c.Set("b", 2) + + // bump 'a' so 'b' is less frequent + if _, ok := c.Get("a"); !ok { + t.Fatalf("expected to get 'a'") + } + + // Insert 'c' -> evict tail ('b') + c.Set("c", 3) + + if _, ok := c.Get("b"); ok { + t.Fatalf("expected 'b' to be evicted") + } + + if _, ok := c.Get("a"); !ok { + t.Fatalf("expected 'a' to remain in cache") + } + + if v, ok := c.Get("c"); !ok || v.(int) != 3 { + t.Fatalf("expected 'c'=3 in cache, got %v, ok=%v", v, ok) + } +} + +func TestCAWOLFU_EvictMethodOrder(t *testing.T) { + c, err := NewCAWOLFU(2) + if err != nil { + t.Fatalf("NewCAWOLFU error: %v", err) + } + + c.Set("a", 1) + c.Set("b", 2) + + // Without additional access, tail is 'a' (inserted first with same count) + key, ok := c.Evict() + if !ok || key != "a" { + t.Fatalf("expected to evict 'a' first, got %q ok=%v", key, ok) + } + + key, ok = c.Evict() + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' second, got %q ok=%v", key, ok) + } +} + +func TestCAWOLFU_ZeroCapacity_NoOp(t *testing.T) { + c, err := NewCAWOLFU(0) + if err != nil { + t.Fatalf("NewCAWOLFU error: %v", err) + } + + c.Set("a", 1) + + if _, ok := c.Get("a"); ok { + t.Fatalf("expected Get to miss on zero-capacity cache") + } + + if key, ok := c.Evict(); ok || key != "" { + t.Fatalf("expected no eviction on zero-capacity, got %q ok=%v", key, ok) + } +} + +func TestCAWOLFU_Delete_RemovesItem(t *testing.T) { + c, err := NewCAWOLFU(2) + if err != nil { + t.Fatalf("NewCAWOLFU error: %v", err) + } + + c.Set("a", 1) + c.Set("b", 2) + c.Delete("a") + + if _, ok := c.Get("a"); ok { + t.Fatalf("expected 'a' to be deleted") + } + + key, ok := c.Evict() + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' as remaining item, got %q ok=%v", key, ok) + } +} +pkg/eviction/lru_test.go:1: 1-92 lines are duplicate of `pkg/eviction/cawolfu_test.go:1-91` (dupl) +package eviction + +import "testing" + +func TestLRU_EvictsLeastRecentlyUsedOnSet(t *testing.T) { + lru, err := NewLRUAlgorithm(2) + if err != nil { + t.Fatalf("NewLRUAlgorithm error: %v", err) + } + + lru.Set("a", 1) + lru.Set("b", 2) + + // Access "a" so that "b" becomes the least recently used + if _, ok := lru.Get("a"); !ok { + t.Fatalf("expected to get 'a'") + } + + // Insert "c"; should evict "b" + lru.Set("c", 3) + + if _, ok := lru.Get("b"); ok { + t.Fatalf("expected 'b' to be evicted") + } + + if _, ok := lru.Get("a"); !ok { + t.Fatalf("expected 'a' to remain in cache") + } + + if v, ok := lru.Get("c"); !ok || v.(int) != 3 { + t.Fatalf("expected 'c'=3 in cache, got %v, ok=%v", v, ok) + } +} + +func TestLRU_EvictMethodOrder(t *testing.T) { + lru, err := NewLRUAlgorithm(2) + if err != nil { + t.Fatalf("NewLRUAlgorithm error: %v", err) + } + + lru.Set("a", 1) + lru.Set("b", 2) + + // After two inserts, tail should be "a" + key, ok := lru.Evict() + if !ok || key != "a" { + t.Fatalf("expected to evict 'a' first, got %q ok=%v", key, ok) + } + + key, ok = lru.Evict() + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' second, got %q ok=%v", key, ok) + } +} + +func TestLRU_ZeroCapacity_NoOp(t *testing.T) { + lru, err := NewLRUAlgorithm(0) + if err != nil { + t.Fatalf("NewLRUAlgorithm error: %v", err) + } + + lru.Set("a", 1) + + if _, ok := lru.Get("a"); ok { + t.Fatalf("expected Get to miss on zero-capacity cache") + } + + if key, ok := lru.Evict(); ok || key != "" { + t.Fatalf("expected no eviction on zero-capacity, got %q ok=%v", key, ok) + } +} + +func TestLRU_Delete_RemovesItem(t *testing.T) { + lru, err := NewLRUAlgorithm(2) + if err != nil { + t.Fatalf("NewLRUAlgorithm error: %v", err) + } + + lru.Set("a", 1) + lru.Set("b", 2) + lru.Delete("a") + + if _, ok := lru.Get("a"); ok { + t.Fatalf("expected 'a' to be deleted") + } + + // Evict should not return deleted key + key, ok := lru.Evict() + if !ok || key != "b" { + t.Fatalf("expected to evict 'b' as remaining item, got %q ok=%v", key, ok) + } +} +tests/integration/dist_rebalance_test.go:256:14: do not define dynamic errors, use wrapped static errors instead: "fmt.Errorf(\"unexpected status %d\", resp.StatusCode)" (err113) + lastErr = fmt.Errorf("unexpected status %d", resp.StatusCode) + ^ +pkg/eviction/arc_test.go:14:35: Error return value is not checked (errcheck) + if v, ok := arc.Get("a"); !ok || v.(int) != 1 { + ^ +pkg/eviction/arc_test.go:78:35: Error return value is not checked (errcheck) + if v, ok := arc.Get("a"); !ok || v.(int) != 10 { + ^ +pkg/eviction/cawolfu_test.go:30:33: Error return value is not checked (errcheck) + if v, ok := c.Get("c"); !ok || v.(int) != 3 { + ^ +tests/hypercache_get_multiple_test.go:30:14: Error return value of `cache.Set` is not checked (errcheck) + cache.Set(context.TODO(), "key1", 1, 0) + ^ +tests/hypercache_get_multiple_test.go:31:14: Error return value of `cache.Set` is not checked (errcheck) + cache.Set(context.TODO(), "key2", 2, 0) + ^ +tests/hypercache_get_multiple_test.go:32:14: Error return value of `cache.Set` is not checked (errcheck) + cache.Set(context.TODO(), "key3", 3, 0) + ^ +tests/hypercache_mgmt_dist_test.go:142:23: Error return value of `resp.Body.Close` is not checked (errcheck) + defer resp.Body.Close() + ^ +tests/hypercache_set_test.go:75:18: Error return value of `cache.Stop` is not checked (errcheck) + defer cache.Stop(context.TODO()) + ^ +tests/hypercache_trigger_eviction_test.go:19:15: Error return value of `hc.Stop` is not checked (errcheck) + defer hc.Stop(context.TODO()) + ^ +tests/management_http_test.go:34:15: Error return value of `hc.Stop` is not checked (errcheck) + defer hc.Stop(ctx) + ^ +pkg/eviction/arc_test.go:14:35: type assertion must be checked (forcetypeassert) + if v, ok := arc.Get("a"); !ok || v.(int) != 1 { + ^ +pkg/eviction/arc_test.go:78:35: type assertion must be checked (forcetypeassert) + if v, ok := arc.Get("a"); !ok || v.(int) != 10 { + ^ +pkg/eviction/cawolfu_test.go:30:33: type assertion must be checked (forcetypeassert) + if v, ok := c.Get("c"); !ok || v.(int) != 3 { + ^ +tests/hypercache_distmemory_heartbeat_sampling_test.go:13:6: Function 'TestHeartbeatSamplingAndTransitions' has too many statements (44 > 40) (funlen) +func TestHeartbeatSamplingAndTransitions(t *testing.T) { //nolint:paralleltest + ^ +tests/hypercache_distmemory_heartbeat_test.go:14:6: Function 'TestDistMemoryHeartbeatLiveness' has too many statements (75 > 40) (funlen) +func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest + ^ +tests/hypercache_distmemory_integration_test.go:15:6: Function 'TestDistMemoryForwardingReplication' has too many statements (49 > 40) (funlen) +func TestDistMemoryForwardingReplication(t *testing.T) { + ^ +tests/hypercache_distmemory_stale_quorum_test.go:14:6: Function 'TestDistMemoryStaleQuorum' has too many statements (57 > 40) (funlen) +func TestDistMemoryStaleQuorum(t *testing.T) { + ^ +tests/hypercache_distmemory_tiebreak_test.go:15:6: Function 'TestDistMemoryVersionTieBreak' has too many statements (44 > 40) (funlen) +func TestDistMemoryVersionTieBreak(t *testing.T) { //nolint:paralleltest + ^ +tests/hypercache_distmemory_versioning_test.go:17:6: Function 'TestDistMemoryVersioningQuorum' has too many statements (51 > 40) (funlen) +func TestDistMemoryVersioningQuorum(t *testing.T) { //nolint:paralleltest + ^ +tests/hypercache_distmemory_write_quorum_test.go:76:6: Function 'TestWriteQuorumFailure' has too many statements (47 > 40) (funlen) +func TestWriteQuorumFailure(t *testing.T) { + ^ +tests/hypercache_http_merkle_test.go:15:6: Function 'TestHTTPFetchMerkle' has too many statements (56 > 40) (funlen) +func TestHTTPFetchMerkle(t *testing.T) { + ^ +tests/management_http_test.go:19:6: Function 'TestManagementHTTP_BasicEndpoints' has too many statements (42 > 40) (funlen) +func TestManagementHTTP_BasicEndpoints(t *testing.T) { + ^ +tests/hypercache_distmemory_heartbeat_test.go:14:1: cognitive complexity 41 of func `TestDistMemoryHeartbeatLiveness` is high (> 30) (gocognit) +func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest +^ +tests/integration/dist_phase1_test.go:115:1: cognitive complexity 42 of func `valueOK` is high (> 30) (gocognit) +func valueOK(v any) bool { //nolint:ireturn +^ +pkg/cache/cmap_test.go:50:9: string `test` has 15 occurrences, make it a constant (goconst) + key := "test" + ^ +pkg/cache/cmap_test.go:68:3: string `key1` has 7 occurrences, make it a constant (goconst) + "key1": 1, + ^ +pkg/cache/cmap_test.go:69:3: string `key2` has 7 occurrences, make it a constant (goconst) + "key2": 2, + ^ +pkg/cache/cmap_test.go:70:3: string `key3` has 5 occurrences, make it a constant (goconst) + "key3": 3, + ^ +pkg/cache/item_test.go:34:21: string `test` has 15 occurrences, make it a constant (goconst) + item := &Item{Key: "test", Value: "value"} + ^ +pkg/cache/v2/cmap_test.go:75:15: string `test_value` has 5 occurrences, make it a constant (goconst) + Value: "test_value", + ^ +tests/hypercache_get_multiple_test.go:26:25: string `key1` has 12 occurrences, make it a constant (goconst) + keys: []string{"key1", "key2", "key3"}, + ^ +tests/hypercache_get_multiple_test.go:26:33: string `key2` has 9 occurrences, make it a constant (goconst) + keys: []string{"key1", "key2", "key3"}, + ^ +tests/hypercache_get_multiple_test.go:26:41: string `key3` has 6 occurrences, make it a constant (goconst) + keys: []string{"key1", "key2", "key3"}, + ^ +tests/hypercache_get_or_set_test.go:27:19: string `value1` has 7 occurrences, make it a constant (goconst) + value: "value1", + ^ +tests/hypercache_get_or_set_test.go:35:19: string `value2` has 6 occurrences, make it a constant (goconst) + value: "value2", + ^ +tests/hypercache_get_test.go:27:19: string `key1` has 12 occurrences, make it a constant (goconst) + key: "key1", + ^ +tests/hypercache_get_test.go:28:19: string `value1` has 7 occurrences, make it a constant (goconst) + value: "value1", + ^ +tests/hypercache_get_test.go:35:19: string `key2` has 9 occurrences, make it a constant (goconst) + key: "key2", + ^ +tests/hypercache_get_test.go:36:19: string `value2` has 6 occurrences, make it a constant (goconst) + value: "value2", + ^ +tests/hypercache_set_test.go:25:19: string `key1` has 12 occurrences, make it a constant (goconst) + key: "key1", + ^ +tests/hypercache_set_test.go:26:19: string `value1` has 7 occurrences, make it a constant (goconst) + value: "value1", + ^ +tests/hypercache_set_test.go:33:19: string `key2` has 9 occurrences, make it a constant (goconst) + key: "key2", + ^ +tests/hypercache_set_test.go:34:19: string `value2` has 6 occurrences, make it a constant (goconst) + value: "value2", + ^ +tests/hypercache_distmemory_stale_quorum_test.go:77:2: ifElseChain: rewrite if-else to switch statement (gocritic) + if primary == b1.LocalNodeID() { + ^ +tests/integration/dist_phase1_test.go:105:9: elseif: can replace 'else {if cond {}}' with 'else if cond {}' (gocritic) + } else { + ^ +tests/hypercache_get_multiple_test.go:30:5: G104: Errors unhandled (gosec) + cache.Set(context.TODO(), "key1", 1, 0) + ^ +tests/hypercache_get_multiple_test.go:31:5: G104: Errors unhandled (gosec) + cache.Set(context.TODO(), "key2", 2, 0) + ^ +tests/hypercache_get_multiple_test.go:32:5: G104: Errors unhandled (gosec) + cache.Set(context.TODO(), "key3", 3, 0) + ^ +pkg/cache/v2/cmap_test.go:266:22: G115: integer overflow conversion int -> rune (gosec) + cm.Set(string(rune(i)), item) + ^ +pkg/cache/v2/cmap_test.go:277:22: G115: integer overflow conversion int -> rune (gosec) + cm.Get(string(rune(i))) + ^ +pkg/stats/histogramcollector_test.go:291:17: G115: integer overflow conversion uint64 -> int64 (gosec) + growth := int64(after.HeapAlloc) - int64(before.HeapAlloc) + ^ +pkg/stats/histogramcollector_test.go:291:42: G115: integer overflow conversion uint64 -> int64 (gosec) + growth := int64(after.HeapAlloc) - int64(before.HeapAlloc) + ^ +tests/hypercache_http_merkle_test.go:134:58: G115: integer overflow conversion int -> rune (gosec) +func httpKey(i int) string { return "hkey:" + string(rune('a'+i)) } + ^ +tests/hypercache_http_merkle_test.go:92:24: net/http.Get must not be called. use net/http.NewRequestWithContext and (*net/http.Client).Do(*http.Request) (noctx) + resp, err := http.Get("http://" + b1.LocalNodeAddr() + "/internal/merkle") + ^ +tests/integration/dist_phase1_test.go:20:22: net.Listen must not be called. use (*net.ListenConfig).Listen (noctx) + l, err := net.Listen("tcp", "127.0.0.1:0") + ^ +tests/management_http_test.go:58:25: (*net/http.Client).Get must not be called. use (*net/http.Client).Do(*http.Request) (noctx) + resp, err := client.Get("http://" + addr + "/health") + ^ +tests/management_http_test.go:68:24: (*net/http.Client).Get must not be called. use (*net/http.Client).Do(*http.Request) (noctx) + resp, err = client.Get("http://" + addr + "/stats") + ^ +tests/management_http_test.go:85:24: (*net/http.Client).Get must not be called. use (*net/http.Client).Do(*http.Request) (noctx) + resp, err = client.Get("http://" + addr + "/config") + ^ +tests/hypercache_http_merkle_test.go:111:5: avoid inline error handling using `if err := ...; err != nil`; use plain assignment `err := ...` (noinlineerr) + if err := b2.SyncWith(ctx, "n1"); err != nil { + ^ +tests/integration/dist_phase1_test.go:123:10: avoid inline error handling using `if err := ...; err != nil`; use plain assignment `err := ...` (noinlineerr) + if b, err := base64.StdEncoding.DecodeString(s); err == nil && string(b) == "v1" { + ^ +tests/merkle_sync_test.go:69:5: avoid inline error handling using `if err := ...; err != nil`; use plain assignment `err := ...` (noinlineerr) + if err := dmB.SyncWith(ctx, string(dmA.LocalNodeID())); err != nil { + ^ +pkg/cache/cmap_test.go:19:1: Function TestNew missing the call to method parallel (paralleltest) +func TestNew(t *testing.T) { +^ +pkg/cache/cmap_test.go:30:1: Function TestNewStringer missing the call to method parallel (paralleltest) +func TestNewStringer(t *testing.T) { +^ +pkg/cache/cmap_test.go:37:1: Function TestNewWithCustomShardingFunction missing the call to method parallel (paralleltest) +func TestNewWithCustomShardingFunction(t *testing.T) { +^ +pkg/cache/cmap_test.go:48:1: Function TestSetAndGet missing the call to method parallel (paralleltest) +func TestSetAndGet(t *testing.T) { +^ +pkg/cache/cmap_test.go:65:1: Function TestMSet missing the call to method parallel (paralleltest) +func TestMSet(t *testing.T) { +^ +pkg/cache/cmap_test.go:82:1: Function TestUpsert missing the call to method parallel (paralleltest) +func TestUpsert(t *testing.T) { +^ +pkg/cache/cmap_test.go:113:1: Function TestSetIfAbsent missing the call to method parallel (paralleltest) +func TestSetIfAbsent(t *testing.T) { +^ +pkg/cache/cmap_test.go:133:1: Function TestHas missing the call to method parallel (paralleltest) +func TestHas(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:14:1: Function TestNew missing the call to method parallel (paralleltest) +func TestNew(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:37:1: Function TestGetShardIndex missing the call to method parallel (paralleltest) +func TestGetShardIndex(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:47:2: Range statement for test TestGetShardIndex missing the call to method parallel in test Run (paralleltest) + for _, tt := range tests { + ^ +pkg/cache/v2/cmap_test.go:57:1: Function TestGetShard missing the call to method parallel (paralleltest) +func TestGetShard(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:72:1: Function TestSetAndGet missing the call to method parallel (paralleltest) +func TestSetAndGet(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:99:1: Function TestHas missing the call to method parallel (paralleltest) +func TestHas(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:119:1: Function TestPop missing the call to method parallel (paralleltest) +func TestPop(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:146:1: Function TestRemove missing the call to method parallel (paralleltest) +func TestRemove(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:164:1: Function TestCount missing the call to method parallel (paralleltest) +func TestCount(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:195:1: Function TestIterBuffered missing the call to method parallel (paralleltest) +func TestIterBuffered(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:227:1: Function TestClear missing the call to method parallel (paralleltest) +func TestClear(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:251:1: Function TestConcurrentAccess missing the call to method parallel (paralleltest) +func TestConcurrentAccess(t *testing.T) { +^ +pkg/cache/v2/cmap_test.go:284:1: Function TestSnapshotPanic missing the call to method parallel (paralleltest) +func TestSnapshotPanic(t *testing.T) { +^ +pkg/eviction/arc_test.go:5:1: Function TestARC_BasicSetGetAndEvict missing the call to method parallel (paralleltest) +func TestARC_BasicSetGetAndEvict(t *testing.T) { +^ +pkg/eviction/arc_test.go:29:1: Function TestARC_ZeroCapacity_NoOp missing the call to method parallel (paralleltest) +func TestARC_ZeroCapacity_NoOp(t *testing.T) { +^ +pkg/eviction/arc_test.go:46:1: Function TestARC_Delete_RemovesResidentAndGhost missing the call to method parallel (paralleltest) +func TestARC_Delete_RemovesResidentAndGhost(t *testing.T) { +^ +pkg/eviction/arc_test.go:65:1: Function TestARC_B1GhostHitPromotesToT2 missing the call to method parallel (paralleltest) +func TestARC_B1GhostHitPromotesToT2(t *testing.T) { +^ +pkg/eviction/cawolfu_test.go:5:1: Function TestCAWOLFU_EvictsLeastFrequentTail missing the call to method parallel (paralleltest) +func TestCAWOLFU_EvictsLeastFrequentTail(t *testing.T) { +^ +pkg/eviction/cawolfu_test.go:35:1: Function TestCAWOLFU_EvictMethodOrder missing the call to method parallel (paralleltest) +func TestCAWOLFU_EvictMethodOrder(t *testing.T) { +^ +pkg/eviction/cawolfu_test.go:56:1: Function TestCAWOLFU_ZeroCapacity_NoOp missing the call to method parallel (paralleltest) +func TestCAWOLFU_ZeroCapacity_NoOp(t *testing.T) { +^ +pkg/eviction/cawolfu_test.go:73:1: Function TestCAWOLFU_Delete_RemovesItem missing the call to method parallel (paralleltest) +func TestCAWOLFU_Delete_RemovesItem(t *testing.T) { +^ +pkg/eviction/clock_test.go:5:1: Function TestClock_EvictsWhenHandFindsColdPage missing the call to method parallel (paralleltest) +func TestClock_EvictsWhenHandFindsColdPage(t *testing.T) { +^ +pkg/eviction/clock_test.go:48:1: Function TestClock_ZeroCapacity_NoOp missing the call to method parallel (paralleltest) +func TestClock_ZeroCapacity_NoOp(t *testing.T) { +^ +pkg/eviction/clock_test.go:65:1: Function TestClock_Delete_RemovesItem missing the call to method parallel (paralleltest) +func TestClock_Delete_RemovesItem(t *testing.T) { +^ +pkg/eviction/lfu_test.go:6:1: Function TestLFU_EvictsOldestOnTie_InsertOrder missing the call to method parallel (paralleltest) +func TestLFU_EvictsOldestOnTie_InsertOrder(t *testing.T) { +^ +pkg/eviction/lfu_test.go:37:1: Function TestLFU_EvictsOldestOnTie_AccessOrder missing the call to method parallel (paralleltest) +func TestLFU_EvictsOldestOnTie_AccessOrder(t *testing.T) { +^ +pkg/eviction/lfu_test.go:67:1: Function TestLFU_ZeroCapacity_NoOp missing the call to method parallel (paralleltest) +func TestLFU_ZeroCapacity_NoOp(t *testing.T) { +^ +pkg/eviction/lfu_test.go:84:1: Function TestLFU_Delete_RemovesItem missing the call to method parallel (paralleltest) +func TestLFU_Delete_RemovesItem(t *testing.T) { +^ +pkg/eviction/lru_test.go:5:1: Function TestLRU_EvictsLeastRecentlyUsedOnSet missing the call to method parallel (paralleltest) +func TestLRU_EvictsLeastRecentlyUsedOnSet(t *testing.T) { +^ +pkg/eviction/lru_test.go:35:1: Function TestLRU_EvictMethodOrder missing the call to method parallel (paralleltest) +func TestLRU_EvictMethodOrder(t *testing.T) { +^ +pkg/eviction/lru_test.go:56:1: Function TestLRU_ZeroCapacity_NoOp missing the call to method parallel (paralleltest) +func TestLRU_ZeroCapacity_NoOp(t *testing.T) { +^ +pkg/eviction/lru_test.go:73:1: Function TestLRU_Delete_RemovesItem missing the call to method parallel (paralleltest) +func TestLRU_Delete_RemovesItem(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:12:1: Function TestHistogramStatsCollector_BasicAggregates missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_BasicAggregates(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:51:1: Function TestHistogramStatsCollector_DecrStoresNegative missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_DecrStoresNegative(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:71:1: Function TestHistogramStatsCollector_Median missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_Median(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:91:1: Function TestHistogramStatsCollector_Percentile missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_Percentile(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:115:1: Function TestHistogramStatsCollector_EmptyStat missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_EmptyStat(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:136:1: Function TestHistogramStatsCollector_BoundedSamples missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_BoundedSamples(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:173:1: Function TestHistogramStatsCollector_ConcurrentRecord missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_ConcurrentRecord(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:226:1: Function TestHistogramStatsCollector_GetStatsSnapshotIsolated missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_GetStatsSnapshotIsolated(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:254:1: Function TestHistogramStatsCollector_NoMemoryLeak missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_NoMemoryLeak(t *testing.T) { +^ +pkg/stats/histogramcollector_test.go:307:1: Function TestHistogramStatsCollector_AtomicMinMaxRace missing the call to method parallel (paralleltest) +func TestHistogramStatsCollector_AtomicMinMaxRace(t *testing.T) { +^ +tests/integration/dist_rebalance_replica_diff_test.go:21:2: Consider preallocating baseOpts with capacity 4 (prealloc) + baseOpts := []backend.DistMemoryOption{ + ^ +tests/integration/dist_rebalance_replica_diff_throttle_test.go:20:2: Consider preallocating base with capacity 5 (prealloc) + base := []backend.DistMemoryOption{ + ^ +tests/integration/dist_rebalance_test.go:177:2: Consider preallocating opts with capacity 6 + len(extra) (prealloc) + opts := []backend.DistMemoryOption{ + ^ +pkg/stats/histogramcollector_test.go:137:8: const cap has same name as predeclared identifier (predeclared) + const cap = 8 + ^ +pkg/stats/histogramcollector_test.go:259:8: const cap has same name as predeclared identifier (predeclared) + const cap = 1024 + ^ +pkg/cache/cmap_test.go:38:25: unused-parameter: parameter 'key' seems to be unused, consider removing or renaming it as _ (revive) + customSharding := func(key string) uint32 { + ^ +pkg/cache/cmap_test.go:169:37: unused-parameter: parameter 'k' seems to be unused, consider removing or renaming it as _ (revive) + removed := cmap.RemoveCb(key, func(k string, v int, exists bool) bool { + ^ +pkg/cache/cmap_test.go:423:22: unused-parameter: parameter 't' seems to be unused, consider removing or renaming it as _ (revive) +func TestConcurrency(t *testing.T) { + ^ +pkg/cache/v2/cmap_test.go:251:27: unused-parameter: parameter 't' seems to be unused, consider removing or renaming it as _ (revive) +func TestConcurrentAccess(t *testing.T) { + ^ +pkg/cache/v2/cmap_test.go:257:3: use-waitgroup-go: replace wg.Add()...go {...wg.Done()...} with wg.Go(...) (revive) + wg.Add(1) + ^ +pkg/cache/v2/cmap_test.go:272:3: use-waitgroup-go: replace wg.Add()...go {...wg.Done()...} with wg.Go(...) (revive) + wg.Add(1) + ^ +pkg/eviction/cawolfu.go:14: line-length-limit: line is 151 characters, out of limit 150 (revive) + list *CAWOLFULinkedList // linked list to store the items in the cache, with the most frequently used items at the front +pkg/stats/histogramcollector_test.go:137:2: redefines-builtin-id: redefinition of the built-in function cap (revive) + const cap = 8 + ^ +pkg/stats/histogramcollector_test.go:259:2: redefines-builtin-id: redefinition of the built-in function cap (revive) + const cap = 1024 + ^ +pkg/stats/histogramcollector_test.go:268:2: call-to-gc: explicit call to the garbage collector (revive) + runtime.GC() + ^ +pkg/stats/histogramcollector_test.go:281:2: call-to-gc: explicit call to the garbage collector (revive) + runtime.GC() + ^ +tests/benchmark/hypercache_concurrency_benchmark_test.go:1:9: package-directory-mismatch: package name "tests" does not match directory name "benchmark" (revive) +package tests + ^ +tests/benchmark/hypercache_list_benchmark_test.go:1:9: package-directory-mismatch: package name "tests" does not match directory name "benchmark" (revive) +package tests + ^ +tests/benchmark/hypercache_set_benchmark_test.go:1:9: package-directory-mismatch: package name "tests" does not match directory name "benchmark" (revive) +package tests + ^ +tests/hypercache_distmemory_heartbeat_sampling_test.go:13:1: function-length: maximum number of lines per function exceeded; max 75 but got 79 (revive) +func TestHeartbeatSamplingAndTransitions(t *testing.T) { //nolint:paralleltest + ctx := context.Background() + ring := cluster.NewRing(cluster.WithReplication(1)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // three peers plus local + n1 := cluster.NewNode("", "n1") + n2 := cluster.NewNode("", "n2") + n3 := cluster.NewNode("", "n3") + + b1i, _ := backend.NewDistMemory( + ctx, + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistHeartbeat(15*time.Millisecond, 40*time.Millisecond, 90*time.Millisecond), + backend.WithDistHeartbeatSample(0), // probe all peers per tick for deterministic transition + ) + + _ = b1i // for clarity + + b2i, _ := backend.NewDistMemory(ctx, backend.WithDistMembership(membership, n2), backend.WithDistTransport(transport)) + b3i, _ := backend.NewDistMemory(ctx, backend.WithDistMembership(membership, n3), backend.WithDistTransport(transport)) + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + // Unregister b2 to simulate failure so it becomes suspect then dead. + transport.Unregister(string(n2.ID)) + + // Wait long enough for dead transition. Because of sampling (k=1) we give generous time window. + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + m := b1.Metrics() + if m.NodesDead > 0 { // transition observed + break + } + + time.Sleep(10 * time.Millisecond) + } + + mfinal := b1.Metrics() + if mfinal.NodesSuspect == 0 { + t.Fatalf("expected at least one suspect transition, got 0") + } + + if mfinal.NodesDead == 0 { + t.Fatalf("expected at least one dead transition, got 0") + } + + // ensure membership version advanced beyond initial additions (>= number of transitions + initial upserts) + snap := b1.DistMembershipSnapshot() + verAny := snap["version"] + + ver, _ := verAny.(uint64) + if ver < 3 { // initial upserts already increment version; tolerate timing variance + t.Fatalf("expected membership version >=4, got %v", verAny) + } + + _ = b3 // silence linter for now (future: more assertions) +} +tests/hypercache_distmemory_heartbeat_sampling_test.go:87:12: unchecked-type-assertion: type cast result is unchecked in verAny.(uint64) - type assertion result ignored (revive) + ver, _ := verAny.(uint64) + ^ +tests/hypercache_distmemory_heartbeat_test.go:14:1: cognitive-complexity: function TestDistMemoryHeartbeatLiveness has cognitive complexity 41 (> max enabled 15) (revive) +func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest + // Intervals chosen so the test tolerates the 3-5x slowdown imposed by + // the race detector. Previous values (interval=30ms, dead=120ms) were + // tight enough that a delayed heartbeat tick could push *alive* nodes + // past deadAfter under -race, removing them from membership. + interval := 80 * time.Millisecond + suspectAfter := 4 * interval // 320ms + deadAfter := 8 * interval // 640ms + + ring := cluster.NewRing(cluster.WithReplication(1)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // nodes + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + // backend for node1 with heartbeat enabled + b1i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistHeartbeat(interval, suspectAfter, deadAfter), + ) + if err != nil { + t.Fatalf("b1: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + + // add peers (without heartbeat loops themselves) + b2i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("b2: %v", err) + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b2) + + b3i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("b3: %v", err) + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + // Wait until heartbeat marks peers alive (initial success probes) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + aliveCount := 0 + for _, n := range membership.List() { + if n.State == cluster.NodeAlive { + aliveCount++ + } + } + + if aliveCount == 3 { + break + } + + time.Sleep(20 * time.Millisecond) + } + + // Simulate node2 becoming unresponsive by removing it from transport registry. + // (Simplest way: do not respond to health; drop entry.) + transport.Unregister(string(n2.ID)) + + // Wait until node2 transitions to suspect then removed. + var sawSuspect bool + + deadline = time.Now().Add(2 * deadAfter) + for time.Now().Before(deadline) { + foundN2 := false + for _, n := range membership.List() { + if n.ID == n2.ID { + foundN2 = true + + if n.State == cluster.NodeSuspect { + sawSuspect = true + } + } + } + + if !foundN2 && sawSuspect { + break + } // removed after suspicion observed + + time.Sleep(20 * time.Millisecond) + } + + if !sawSuspect { + t.Fatalf("node2 never became suspect") + } + + // ensure removed + for _, n := range membership.List() { + if n.ID == n2.ID { + t.Fatalf("node2 still present, state=%s", n.State) + } + } + + // Node3 should remain alive; ensure not removed + n3Present := false + for _, n := range membership.List() { + if n.ID == n3.ID { + n3Present = true + + if n.State != cluster.NodeAlive { + t.Fatalf("node3 not alive: %s", n.State) + } + } + } + + if !n3Present { + t.Fatalf("node3 missing") + } + + // Metrics sanity: at least one heartbeat failure and success recorded. + m := b1.Metrics() + if m.HeartbeatFailure == 0 { + t.Errorf("expected heartbeat failures > 0") + } + + if m.HeartbeatSuccess == 0 { + t.Errorf("expected heartbeat successes > 0") + } + + if m.NodesRemoved == 0 { + t.Errorf("expected nodes removed metric > 0") + } +} +tests/hypercache_distmemory_heartbeat_test.go:14:1: cyclomatic: function TestDistMemoryHeartbeatLiveness has cyclomatic complexity 27 (> max enabled 15) (revive) +func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest + // Intervals chosen so the test tolerates the 3-5x slowdown imposed by + // the race detector. Previous values (interval=30ms, dead=120ms) were + // tight enough that a delayed heartbeat tick could push *alive* nodes + // past deadAfter under -race, removing them from membership. + interval := 80 * time.Millisecond + suspectAfter := 4 * interval // 320ms + deadAfter := 8 * interval // 640ms + + ring := cluster.NewRing(cluster.WithReplication(1)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // nodes + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + // backend for node1 with heartbeat enabled + b1i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistHeartbeat(interval, suspectAfter, deadAfter), + ) + if err != nil { + t.Fatalf("b1: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + + // add peers (without heartbeat loops themselves) + b2i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("b2: %v", err) + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b2) + + b3i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("b3: %v", err) + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + // Wait until heartbeat marks peers alive (initial success probes) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + aliveCount := 0 + for _, n := range membership.List() { + if n.State == cluster.NodeAlive { + aliveCount++ + } + } + + if aliveCount == 3 { + break + } + + time.Sleep(20 * time.Millisecond) + } + + // Simulate node2 becoming unresponsive by removing it from transport registry. + // (Simplest way: do not respond to health; drop entry.) + transport.Unregister(string(n2.ID)) + + // Wait until node2 transitions to suspect then removed. + var sawSuspect bool + + deadline = time.Now().Add(2 * deadAfter) + for time.Now().Before(deadline) { + foundN2 := false + for _, n := range membership.List() { + if n.ID == n2.ID { + foundN2 = true + + if n.State == cluster.NodeSuspect { + sawSuspect = true + } + } + } + + if !foundN2 && sawSuspect { + break + } // removed after suspicion observed + + time.Sleep(20 * time.Millisecond) + } + + if !sawSuspect { + t.Fatalf("node2 never became suspect") + } + + // ensure removed + for _, n := range membership.List() { + if n.ID == n2.ID { + t.Fatalf("node2 still present, state=%s", n.State) + } + } + + // Node3 should remain alive; ensure not removed + n3Present := false + for _, n := range membership.List() { + if n.ID == n3.ID { + n3Present = true + + if n.State != cluster.NodeAlive { + t.Fatalf("node3 not alive: %s", n.State) + } + } + } + + if !n3Present { + t.Fatalf("node3 missing") + } + + // Metrics sanity: at least one heartbeat failure and success recorded. + m := b1.Metrics() + if m.HeartbeatFailure == 0 { + t.Errorf("expected heartbeat failures > 0") + } + + if m.HeartbeatSuccess == 0 { + t.Errorf("expected heartbeat successes > 0") + } + + if m.NodesRemoved == 0 { + t.Errorf("expected nodes removed metric > 0") + } +} +tests/hypercache_distmemory_heartbeat_test.go:14:1: function-length: maximum number of statements per function exceeded; max 50 but got 75 (revive) +func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest + // Intervals chosen so the test tolerates the 3-5x slowdown imposed by + // the race detector. Previous values (interval=30ms, dead=120ms) were + // tight enough that a delayed heartbeat tick could push *alive* nodes + // past deadAfter under -race, removing them from membership. + interval := 80 * time.Millisecond + suspectAfter := 4 * interval // 320ms + deadAfter := 8 * interval // 640ms + + ring := cluster.NewRing(cluster.WithReplication(1)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // nodes + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + // backend for node1 with heartbeat enabled + b1i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistHeartbeat(interval, suspectAfter, deadAfter), + ) + if err != nil { + t.Fatalf("b1: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + + // add peers (without heartbeat loops themselves) + b2i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("b2: %v", err) + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b2) + + b3i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("b3: %v", err) + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + // Wait until heartbeat marks peers alive (initial success probes) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + aliveCount := 0 + for _, n := range membership.List() { + if n.State == cluster.NodeAlive { + aliveCount++ + } + } + + if aliveCount == 3 { + break + } + + time.Sleep(20 * time.Millisecond) + } + + // Simulate node2 becoming unresponsive by removing it from transport registry. + // (Simplest way: do not respond to health; drop entry.) + transport.Unregister(string(n2.ID)) + + // Wait until node2 transitions to suspect then removed. + var sawSuspect bool + + deadline = time.Now().Add(2 * deadAfter) + for time.Now().Before(deadline) { + foundN2 := false + for _, n := range membership.List() { + if n.ID == n2.ID { + foundN2 = true + + if n.State == cluster.NodeSuspect { + sawSuspect = true + } + } + } + + if !foundN2 && sawSuspect { + break + } // removed after suspicion observed + + time.Sleep(20 * time.Millisecond) + } + + if !sawSuspect { + t.Fatalf("node2 never became suspect") + } + + // ensure removed + for _, n := range membership.List() { + if n.ID == n2.ID { + t.Fatalf("node2 still present, state=%s", n.State) + } + } + + // Node3 should remain alive; ensure not removed + n3Present := false + for _, n := range membership.List() { + if n.ID == n3.ID { + n3Present = true + + if n.State != cluster.NodeAlive { + t.Fatalf("node3 not alive: %s", n.State) + } + } + } + + if !n3Present { + t.Fatalf("node3 missing") + } + + // Metrics sanity: at least one heartbeat failure and success recorded. + m := b1.Metrics() + if m.HeartbeatFailure == 0 { + t.Errorf("expected heartbeat failures > 0") + } + + if m.HeartbeatSuccess == 0 { + t.Errorf("expected heartbeat successes > 0") + } + + if m.NodesRemoved == 0 { + t.Errorf("expected nodes removed metric > 0") + } +} +tests/hypercache_distmemory_heartbeat_test.go:14:1: function-length: maximum number of lines per function exceeded; max 75 but got 156 (revive) +func TestDistMemoryHeartbeatLiveness(t *testing.T) { //nolint:paralleltest + // Intervals chosen so the test tolerates the 3-5x slowdown imposed by + // the race detector. Previous values (interval=30ms, dead=120ms) were + // tight enough that a delayed heartbeat tick could push *alive* nodes + // past deadAfter under -race, removing them from membership. + interval := 80 * time.Millisecond + suspectAfter := 4 * interval // 320ms + deadAfter := 8 * interval // 640ms + + ring := cluster.NewRing(cluster.WithReplication(1)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // nodes + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + // backend for node1 with heartbeat enabled + b1i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistHeartbeat(interval, suspectAfter, deadAfter), + ) + if err != nil { + t.Fatalf("b1: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + + // add peers (without heartbeat loops themselves) + b2i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("b2: %v", err) + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b2) + + b3i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("b3: %v", err) + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + // Wait until heartbeat marks peers alive (initial success probes) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + aliveCount := 0 + for _, n := range membership.List() { + if n.State == cluster.NodeAlive { + aliveCount++ + } + } + + if aliveCount == 3 { + break + } + + time.Sleep(20 * time.Millisecond) + } + + // Simulate node2 becoming unresponsive by removing it from transport registry. + // (Simplest way: do not respond to health; drop entry.) + transport.Unregister(string(n2.ID)) + + // Wait until node2 transitions to suspect then removed. + var sawSuspect bool + + deadline = time.Now().Add(2 * deadAfter) + for time.Now().Before(deadline) { + foundN2 := false + for _, n := range membership.List() { + if n.ID == n2.ID { + foundN2 = true + + if n.State == cluster.NodeSuspect { + sawSuspect = true + } + } + } + + if !foundN2 && sawSuspect { + break + } // removed after suspicion observed + + time.Sleep(20 * time.Millisecond) + } + + if !sawSuspect { + t.Fatalf("node2 never became suspect") + } + + // ensure removed + for _, n := range membership.List() { + if n.ID == n2.ID { + t.Fatalf("node2 still present, state=%s", n.State) + } + } + + // Node3 should remain alive; ensure not removed + n3Present := false + for _, n := range membership.List() { + if n.ID == n3.ID { + n3Present = true + + if n.State != cluster.NodeAlive { + t.Fatalf("node3 not alive: %s", n.State) + } + } + } + + if !n3Present { + t.Fatalf("node3 missing") + } + + // Metrics sanity: at least one heartbeat failure and success recorded. + m := b1.Metrics() + if m.HeartbeatFailure == 0 { + t.Errorf("expected heartbeat failures > 0") + } + + if m.HeartbeatSuccess == 0 { + t.Errorf("expected heartbeat successes > 0") + } + + if m.NodesRemoved == 0 { + t.Errorf("expected nodes removed metric > 0") + } +} +tests/hypercache_distmemory_integration_test.go:15:1: cognitive-complexity: function TestDistMemoryForwardingReplication has cognitive complexity 19 (> max enabled 15) (revive) +func TestDistMemoryForwardingReplication(t *testing.T) { + ring := cluster.NewRing(cluster.WithReplication(2)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // create two nodes/backends + n1 := cluster.NewNode("", "node1:0") + n2 := cluster.NewNode("", "node2:0") + + b1i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("backend1: %v", err) + } + + b2i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("backend2: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + + transport.Register(b1) + transport.Register(b2) + + // pick keys to exercise distribution (simple deterministic list) + keys := []string{"alpha", "bravo", "charlie", "delta", "echo"} + // write via the node that is primary owner to guarantee placement + replication + for _, k := range keys { + owners := ring.Lookup(k) + if len(owners) == 0 { + t.Fatalf("no owners for key %s", k) + } + + item := &cache.Item{Key: k, Value: k} + + err := item.Valid() + if err != nil { + t.Fatalf("item valid %s: %v", k, err) + } + + target := owners[0] + + var err2 error + + switch target { + case n1.ID: + err2 = b1.Set(context.Background(), item) + case n2.ID: + err2 = b2.Set(context.Background(), item) + default: + t.Fatalf("unexpected owner id %s", target) + } + + if err2 != nil { + t.Fatalf("set %s via %s: %v", k, target, err2) + } + } + + // Each key should be readable via either owner (b1 primary forward) or local if replica + for _, k := range keys { + if _, ok := b1.Get(context.Background(), k); !ok { + t.Fatalf("b1 cannot get key %s", k) + } + + if _, ok := b2.Get(context.Background(), k); !ok { // should forward or local hit + t.Fatalf("b2 cannot get key %s", k) + } + } + + // Check replication: at least one key should physically exist on b2 after writes from b1 when b2 is replica + foundReplica := slices.ContainsFunc(keys, b2.LocalContains) + if !foundReplica { + t.Fatalf("expected at least one replicated key on b2") + } +} +tests/hypercache_distmemory_integration_test.go:15:1: function-length: maximum number of lines per function exceeded; max 75 but got 92 (revive) +func TestDistMemoryForwardingReplication(t *testing.T) { + ring := cluster.NewRing(cluster.WithReplication(2)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // create two nodes/backends + n1 := cluster.NewNode("", "node1:0") + n2 := cluster.NewNode("", "node2:0") + + b1i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("backend1: %v", err) + } + + b2i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("backend2: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + + transport.Register(b1) + transport.Register(b2) + + // pick keys to exercise distribution (simple deterministic list) + keys := []string{"alpha", "bravo", "charlie", "delta", "echo"} + // write via the node that is primary owner to guarantee placement + replication + for _, k := range keys { + owners := ring.Lookup(k) + if len(owners) == 0 { + t.Fatalf("no owners for key %s", k) + } + + item := &cache.Item{Key: k, Value: k} + + err := item.Valid() + if err != nil { + t.Fatalf("item valid %s: %v", k, err) + } + + target := owners[0] + + var err2 error + + switch target { + case n1.ID: + err2 = b1.Set(context.Background(), item) + case n2.ID: + err2 = b2.Set(context.Background(), item) + default: + t.Fatalf("unexpected owner id %s", target) + } + + if err2 != nil { + t.Fatalf("set %s via %s: %v", k, target, err2) + } + } + + // Each key should be readable via either owner (b1 primary forward) or local if replica + for _, k := range keys { + if _, ok := b1.Get(context.Background(), k); !ok { + t.Fatalf("b1 cannot get key %s", k) + } + + if _, ok := b2.Get(context.Background(), k); !ok { // should forward or local hit + t.Fatalf("b2 cannot get key %s", k) + } + } + + // Check replication: at least one key should physically exist on b2 after writes from b1 when b2 is replica + foundReplica := slices.ContainsFunc(keys, b2.LocalContains) + if !foundReplica { + t.Fatalf("expected at least one replicated key on b2") + } +} +tests/hypercache_distmemory_integration_test.go:15:1: cyclomatic: function TestDistMemoryForwardingReplication has cyclomatic complexity 16 (> max enabled 15) (revive) +func TestDistMemoryForwardingReplication(t *testing.T) { + ring := cluster.NewRing(cluster.WithReplication(2)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // create two nodes/backends + n1 := cluster.NewNode("", "node1:0") + n2 := cluster.NewNode("", "node2:0") + + b1i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("backend1: %v", err) + } + + b2i, err := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + ) + if err != nil { + t.Fatalf("backend2: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + + transport.Register(b1) + transport.Register(b2) + + // pick keys to exercise distribution (simple deterministic list) + keys := []string{"alpha", "bravo", "charlie", "delta", "echo"} + // write via the node that is primary owner to guarantee placement + replication + for _, k := range keys { + owners := ring.Lookup(k) + if len(owners) == 0 { + t.Fatalf("no owners for key %s", k) + } + + item := &cache.Item{Key: k, Value: k} + + err := item.Valid() + if err != nil { + t.Fatalf("item valid %s: %v", k, err) + } + + target := owners[0] + + var err2 error + + switch target { + case n1.ID: + err2 = b1.Set(context.Background(), item) + case n2.ID: + err2 = b2.Set(context.Background(), item) + default: + t.Fatalf("unexpected owner id %s", target) + } + + if err2 != nil { + t.Fatalf("set %s via %s: %v", k, target, err2) + } + } + + // Each key should be readable via either owner (b1 primary forward) or local if replica + for _, k := range keys { + if _, ok := b1.Get(context.Background(), k); !ok { + t.Fatalf("b1 cannot get key %s", k) + } + + if _, ok := b2.Get(context.Background(), k); !ok { // should forward or local hit + t.Fatalf("b2 cannot get key %s", k) + } + } + + // Check replication: at least one key should physically exist on b2 after writes from b1 when b2 is replica + foundReplica := slices.ContainsFunc(keys, b2.LocalContains) + if !foundReplica { + t.Fatalf("expected at least one replicated key on b2") + } +} +tests/hypercache_distmemory_remove_readrepair_test.go:13:60: confusing-results: unnamed results of the same type may be confusing, consider using named results (revive) +func newTwoNodeCluster(t *testing.T) (*backend.DistMemory, *backend.DistMemory, *cluster.Ring) { + ^ +tests/hypercache_distmemory_remove_readrepair_test.go:113:1: cyclomatic: function TestDistMemoryReadRepair has cyclomatic complexity 27 (> max enabled 15) (revive) +func TestDistMemoryReadRepair(t *testing.T) { + b1, b2, ring := newTwoNodeCluster(t) + key := "rr-key" + + owners := ring.Lookup(key) + if len(owners) == 0 { + t.Fatalf("no owners") + } + + item := &cache.Item{Key: key, Value: "val"} + + err := item.Valid() + if err != nil { + t.Fatalf("valid: %v", err) + } + + // write via primary + if owners[0] == b1.LocalNodeID() { + err := b1.Set(context.Background(), item) + if err != nil { + t.Fatalf("set: %v", err) + } + } else { + err := b2.Set(context.Background(), item) + if err != nil { + t.Fatalf("set: %v", err) + } + } + + // determine replica node (owners[1]) and drop local copy there manually + if len(owners) < 2 { + t.Skip("replication factor <2") + } + + replica := owners[1] + // optional: t.Logf("owners: %v primary=%s replica=%s", owners, owners[0], replica) + if replica == b1.LocalNodeID() { + b1.DebugDropLocal(key) + } else { + b2.DebugDropLocal(key) + } + + // ensure dropped locally + if replica == b1.LocalNodeID() && b1.LocalContains(key) { + t.Fatalf("replica still has key after drop") + } + + if replica == b2.LocalNodeID() && b2.LocalContains(key) { + t.Fatalf("replica still has key after drop") + } + + // issue Get from a non-owner node to trigger forwarding, then verify owners repaired. + // choose a requester: use node that is neither primary nor replica if possible; with 2 nodes this means primary forwards to replica or + // vice versa. + requester := b1 + if owners[0] == b1.LocalNodeID() && replica == b2.LocalNodeID() { + requester = b2 // request from replica to forward to primary + } else if owners[0] == b2.LocalNodeID() && replica == b1.LocalNodeID() { + requester = b1 + } + + if _, ok := requester.Get(context.Background(), key); !ok { + t.Fatalf("get for read-repair failed") + } + + // after forwarding, both owners should have key locally again + if owners[0] == b1.LocalNodeID() && !b1.LocalContains(key) { + t.Fatalf("primary missing after read repair") + } + + if owners[0] == b2.LocalNodeID() && !b2.LocalContains(key) { + t.Fatalf("primary missing after read repair") + } + + if replica == b1.LocalNodeID() && !b1.LocalContains(key) { + t.Fatalf("replica missing after read repair") + } + + if replica == b2.LocalNodeID() && !b2.LocalContains(key) { + t.Fatalf("replica missing after read repair") + } + + // metrics should show at least one read repair + var repaired bool + + if replica == b1.LocalNodeID() { + repaired = b1.Metrics().ReadRepair > 0 + } else { + repaired = b2.Metrics().ReadRepair > 0 + } + + if !repaired { + t.Fatalf("expected read-repair metric increment") + } +} +tests/hypercache_distmemory_remove_readrepair_test.go:113:1: function-length: maximum number of lines per function exceeded; max 75 but got 93 (revive) +func TestDistMemoryReadRepair(t *testing.T) { + b1, b2, ring := newTwoNodeCluster(t) + key := "rr-key" + + owners := ring.Lookup(key) + if len(owners) == 0 { + t.Fatalf("no owners") + } + + item := &cache.Item{Key: key, Value: "val"} + + err := item.Valid() + if err != nil { + t.Fatalf("valid: %v", err) + } + + // write via primary + if owners[0] == b1.LocalNodeID() { + err := b1.Set(context.Background(), item) + if err != nil { + t.Fatalf("set: %v", err) + } + } else { + err := b2.Set(context.Background(), item) + if err != nil { + t.Fatalf("set: %v", err) + } + } + + // determine replica node (owners[1]) and drop local copy there manually + if len(owners) < 2 { + t.Skip("replication factor <2") + } + + replica := owners[1] + // optional: t.Logf("owners: %v primary=%s replica=%s", owners, owners[0], replica) + if replica == b1.LocalNodeID() { + b1.DebugDropLocal(key) + } else { + b2.DebugDropLocal(key) + } + + // ensure dropped locally + if replica == b1.LocalNodeID() && b1.LocalContains(key) { + t.Fatalf("replica still has key after drop") + } + + if replica == b2.LocalNodeID() && b2.LocalContains(key) { + t.Fatalf("replica still has key after drop") + } + + // issue Get from a non-owner node to trigger forwarding, then verify owners repaired. + // choose a requester: use node that is neither primary nor replica if possible; with 2 nodes this means primary forwards to replica or + // vice versa. + requester := b1 + if owners[0] == b1.LocalNodeID() && replica == b2.LocalNodeID() { + requester = b2 // request from replica to forward to primary + } else if owners[0] == b2.LocalNodeID() && replica == b1.LocalNodeID() { + requester = b1 + } + + if _, ok := requester.Get(context.Background(), key); !ok { + t.Fatalf("get for read-repair failed") + } + + // after forwarding, both owners should have key locally again + if owners[0] == b1.LocalNodeID() && !b1.LocalContains(key) { + t.Fatalf("primary missing after read repair") + } + + if owners[0] == b2.LocalNodeID() && !b2.LocalContains(key) { + t.Fatalf("primary missing after read repair") + } + + if replica == b1.LocalNodeID() && !b1.LocalContains(key) { + t.Fatalf("replica missing after read repair") + } + + if replica == b2.LocalNodeID() && !b2.LocalContains(key) { + t.Fatalf("replica missing after read repair") + } + + // metrics should show at least one read repair + var repaired bool + + if replica == b1.LocalNodeID() { + repaired = b1.Metrics().ReadRepair > 0 + } else { + repaired = b2.Metrics().ReadRepair > 0 + } + + if !repaired { + t.Fatalf("expected read-repair metric increment") + } +} +tests/hypercache_distmemory_stale_quorum_test.go:14:1: function-length: maximum number of lines per function exceeded; max 75 but got 118 (revive) +func TestDistMemoryStaleQuorum(t *testing.T) { + ring := cluster.NewRing(cluster.WithReplication(3)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + b1i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + b2i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + b3i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + key := "sq-key" + + owners := ring.Lookup(key) + if len(owners) != 3 { + t.Skip("replication factor !=3") + } + + // Write initial version via primary + primary := owners[0] + item := &cache.Item{Key: key, Value: "v1"} + + _ = item.Valid() + if primary == b1.LocalNodeID() { + _ = b1.Set(context.Background(), item) + } else if primary == b2.LocalNodeID() { + _ = b2.Set(context.Background(), item) + } else { + _ = b3.Set(context.Background(), item) + } + + // Manually bump version on one replica to simulate a newer write that others missed + // Pick owners[1] as ahead replica + aheadID := owners[1] + ahead := map[cluster.NodeID]*backend.DistMemory{b1.LocalNodeID(): b1, b2.LocalNodeID(): b2, b3.LocalNodeID(): b3}[aheadID] + ahead.DebugInject(&cache.Item{Key: key, Value: "v2", Version: 5, Origin: string(ahead.LocalNodeID()), LastUpdated: time.Now()}) + + // Drop local copy on owners[2] to simulate stale/missing + lagID := owners[2] + lag := map[cluster.NodeID]*backend.DistMemory{b1.LocalNodeID(): b1, b2.LocalNodeID(): b2, b3.LocalNodeID(): b3}[lagID] + lag.DebugDropLocal(key) + + // Issue quorum read from a non-ahead node (choose primary if not ahead, else third) + requester := b1 + if requester.LocalNodeID() == aheadID { + requester = b2 + } + + if requester.LocalNodeID() == aheadID { + requester = b3 + } + + got, ok := requester.Get(context.Background(), key) + if !ok { + t.Fatalf("quorum get failed") + } + + // Value stored as interface{} may be string (not []byte) in this test + if sval, okCast := got.Value.(string); !okCast || sval != "v2" { + t.Fatalf("expected quorum to return newer version v2, got=%v (type %T)", got.Value, got.Value) + } + + // Allow brief repair propagation + time.Sleep(50 * time.Millisecond) + + // All owners should now have v2 (version 5) + for _, oid := range owners { + inst := map[cluster.NodeID]*backend.DistMemory{b1.LocalNodeID(): b1, b2.LocalNodeID(): b2, b3.LocalNodeID(): b3}[oid] + + it, ok2 := inst.Get(context.Background(), key) + if !ok2 || it.Version != 5 { + t.Fatalf("owner %s not repaired to v2 (v5) -> (%v,%v)", oid, ok2, it) + } + } + + // ReadRepair metric should have incremented somewhere + if b1.Metrics().ReadRepair+b2.Metrics().ReadRepair+b3.Metrics().ReadRepair == 0 { + t.Fatalf("expected read repair metric >0") + } +} +tests/hypercache_distmemory_stale_quorum_test.go:14:1: function-length: maximum number of statements per function exceeded; max 50 but got 57 (revive) +func TestDistMemoryStaleQuorum(t *testing.T) { + ring := cluster.NewRing(cluster.WithReplication(3)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + b1i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + b2i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + b3i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + key := "sq-key" + + owners := ring.Lookup(key) + if len(owners) != 3 { + t.Skip("replication factor !=3") + } + + // Write initial version via primary + primary := owners[0] + item := &cache.Item{Key: key, Value: "v1"} + + _ = item.Valid() + if primary == b1.LocalNodeID() { + _ = b1.Set(context.Background(), item) + } else if primary == b2.LocalNodeID() { + _ = b2.Set(context.Background(), item) + } else { + _ = b3.Set(context.Background(), item) + } + + // Manually bump version on one replica to simulate a newer write that others missed + // Pick owners[1] as ahead replica + aheadID := owners[1] + ahead := map[cluster.NodeID]*backend.DistMemory{b1.LocalNodeID(): b1, b2.LocalNodeID(): b2, b3.LocalNodeID(): b3}[aheadID] + ahead.DebugInject(&cache.Item{Key: key, Value: "v2", Version: 5, Origin: string(ahead.LocalNodeID()), LastUpdated: time.Now()}) + + // Drop local copy on owners[2] to simulate stale/missing + lagID := owners[2] + lag := map[cluster.NodeID]*backend.DistMemory{b1.LocalNodeID(): b1, b2.LocalNodeID(): b2, b3.LocalNodeID(): b3}[lagID] + lag.DebugDropLocal(key) + + // Issue quorum read from a non-ahead node (choose primary if not ahead, else third) + requester := b1 + if requester.LocalNodeID() == aheadID { + requester = b2 + } + + if requester.LocalNodeID() == aheadID { + requester = b3 + } + + got, ok := requester.Get(context.Background(), key) + if !ok { + t.Fatalf("quorum get failed") + } + + // Value stored as interface{} may be string (not []byte) in this test + if sval, okCast := got.Value.(string); !okCast || sval != "v2" { + t.Fatalf("expected quorum to return newer version v2, got=%v (type %T)", got.Value, got.Value) + } + + // Allow brief repair propagation + time.Sleep(50 * time.Millisecond) + + // All owners should now have v2 (version 5) + for _, oid := range owners { + inst := map[cluster.NodeID]*backend.DistMemory{b1.LocalNodeID(): b1, b2.LocalNodeID(): b2, b3.LocalNodeID(): b3}[oid] + + it, ok2 := inst.Get(context.Background(), key) + if !ok2 || it.Version != 5 { + t.Fatalf("owner %s not repaired to v2 (v5) -> (%v,%v)", oid, ok2, it) + } + } + + // ReadRepair metric should have incremented somewhere + if b1.Metrics().ReadRepair+b2.Metrics().ReadRepair+b3.Metrics().ReadRepair == 0 { + t.Fatalf("expected read repair metric >0") + } +} +tests/hypercache_distmemory_stale_quorum_test.go:14:1: cyclomatic: function TestDistMemoryStaleQuorum has cyclomatic complexity 16 (> max enabled 15) (revive) +func TestDistMemoryStaleQuorum(t *testing.T) { + ring := cluster.NewRing(cluster.WithReplication(3)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + b1i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + b2i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n2), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + b3i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + key := "sq-key" + + owners := ring.Lookup(key) + if len(owners) != 3 { + t.Skip("replication factor !=3") + } + + // Write initial version via primary + primary := owners[0] + item := &cache.Item{Key: key, Value: "v1"} + + _ = item.Valid() + if primary == b1.LocalNodeID() { + _ = b1.Set(context.Background(), item) + } else if primary == b2.LocalNodeID() { + _ = b2.Set(context.Background(), item) + } else { + _ = b3.Set(context.Background(), item) + } + + // Manually bump version on one replica to simulate a newer write that others missed + // Pick owners[1] as ahead replica + aheadID := owners[1] + ahead := map[cluster.NodeID]*backend.DistMemory{b1.LocalNodeID(): b1, b2.LocalNodeID(): b2, b3.LocalNodeID(): b3}[aheadID] + ahead.DebugInject(&cache.Item{Key: key, Value: "v2", Version: 5, Origin: string(ahead.LocalNodeID()), LastUpdated: time.Now()}) + + // Drop local copy on owners[2] to simulate stale/missing + lagID := owners[2] + lag := map[cluster.NodeID]*backend.DistMemory{b1.LocalNodeID(): b1, b2.LocalNodeID(): b2, b3.LocalNodeID(): b3}[lagID] + lag.DebugDropLocal(key) + + // Issue quorum read from a non-ahead node (choose primary if not ahead, else third) + requester := b1 + if requester.LocalNodeID() == aheadID { + requester = b2 + } + + if requester.LocalNodeID() == aheadID { + requester = b3 + } + + got, ok := requester.Get(context.Background(), key) + if !ok { + t.Fatalf("quorum get failed") + } + + // Value stored as interface{} may be string (not []byte) in this test + if sval, okCast := got.Value.(string); !okCast || sval != "v2" { + t.Fatalf("expected quorum to return newer version v2, got=%v (type %T)", got.Value, got.Value) + } + + // Allow brief repair propagation + time.Sleep(50 * time.Millisecond) + + // All owners should now have v2 (version 5) + for _, oid := range owners { + inst := map[cluster.NodeID]*backend.DistMemory{b1.LocalNodeID(): b1, b2.LocalNodeID(): b2, b3.LocalNodeID(): b3}[oid] + + it, ok2 := inst.Get(context.Background(), key) + if !ok2 || it.Version != 5 { + t.Fatalf("owner %s not repaired to v2 (v5) -> (%v,%v)", oid, ok2, it) + } + } + + // ReadRepair metric should have incremented somewhere + if b1.Metrics().ReadRepair+b2.Metrics().ReadRepair+b3.Metrics().ReadRepair == 0 { + t.Fatalf("expected read repair metric >0") + } +} +tests/hypercache_distmemory_tiebreak_test.go:15:1: function-length: maximum number of lines per function exceeded; max 75 but got 86 (revive) +func TestDistMemoryVersionTieBreak(t *testing.T) { //nolint:paralleltest + interval := 5 * time.Millisecond + ring := cluster.NewRing(cluster.WithReplication(3)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + b1i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistReplication(3), + backend.WithDistHeartbeat(interval, 0, 0), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + backend.WithDistWriteConsistency(backend.ConsistencyQuorum), + ) + b2i, _ := backend.NewDistMemory(context.TODO(), backend.WithDistMembership(membership, n2), backend.WithDistTransport(transport)) + b3i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + // choose key where b1,b2,b3 ordering fixed + key := "tie" + for i := range 3000 { + cand := fmt.Sprintf("tie%d", i) + + owners := b1.DebugOwners(cand) + if len(owners) == 3 && owners[0] == b1.LocalNodeID() && owners[1] == b2.LocalNodeID() && owners[2] == b3.LocalNodeID() { + key = cand + + break + } + } + + // primary write to establish version=1 origin=b1 + err := b1.Set(context.Background(), &cache.Item{Key: key, Value: "v1"}) + if err != nil { + t.Fatalf("initial set: %v", err) + } + + // Inject a fake item on b2 with SAME version but lexicographically larger origin so it should lose. + b2.DebugDropLocal(key) + b2.DebugInject(&cache.Item{Key: key, Value: "alt", Version: 1, Origin: "zzzz"}) + + // Quorum read through b3 triggers selection + repair. + it, ok := b3.Get(context.Background(), key) + if !ok { + t.Fatalf("expected quorum read ok") + } + + if it.Value != "v1" { + t.Fatalf("expected b1 value win, got %v", it.Value) + } + + // Ensure b2 repaired to winning value. + if it2, ok2 := b2.Get(context.Background(), key); !ok2 || it2.Value != "v1" { + t.Fatalf("expected repaired tie-break value on b2") + } +} +tests/hypercache_distmemory_versioning_test.go:17:1: cyclomatic: function TestDistMemoryVersioningQuorum has cyclomatic complexity 16 (> max enabled 15) (revive) +func TestDistMemoryVersioningQuorum(t *testing.T) { //nolint:paralleltest + interval := 10 * time.Millisecond + ring := cluster.NewRing(cluster.WithReplication(3)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // three nodes + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + // enable quorum read + write consistency on b1 + b1i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistReplication(3), + backend.WithDistHeartbeat(interval, 0, 0), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + backend.WithDistWriteConsistency(backend.ConsistencyQuorum), + ) + b2i, _ := backend.NewDistMemory(context.TODO(), backend.WithDistMembership(membership, n2), backend.WithDistTransport(transport)) + b3i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + b1, ok := b1i.(*backend.DistMemory) + + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + // Find a deterministic key where ownership ordering is b1,b2,b3 to avoid forwarding complexities. + key := "k" + for i := range 2000 { // brute force + cand := fmt.Sprintf("k%d", i) + + owners := b1.DebugOwners(cand) + if len(owners) == 3 && owners[0] == b1.LocalNodeID() && owners[1] == b2.LocalNodeID() && owners[2] == b3.LocalNodeID() { + key = cand + + break + } + } + + // Write key via primary. + item1 := &cache.Item{Key: key, Value: "v1"} + + err := b1.Set(context.Background(), item1) + if err != nil { + t.Fatalf("initial set: %v", err) + } + + // Simulate a concurrent stale write from another node with lower version (manual injection on b2). + itemStale := &cache.Item{Key: key, Value: "v0", Version: 0, Origin: "zzz"} + b2.DebugDropLocal(key) + b2.DebugInject(itemStale) + + // Read quorum from node3: should observe latest (v1) and repair b2. + it, ok := b3.Get(context.Background(), key) + if !ok { + t.Fatalf("expected read ok") + } + + if it.Value != "v1" { + t.Fatalf("expected value v1, got %v", it.Value) + } + + // Ensure b2 repaired. + if it2, ok2 := b2.Get(context.Background(), key); !ok2 || it2.Value != "v1" { + t.Fatalf("expected repaired value on b2") + } + + // Simulate reduced acks: unregister one replica and perform write requiring quorum (2 of 3). + transport.Unregister(string(n3.ID)) + + item2 := &cache.Item{Key: key, Value: "v2"} + + err = b1.Set(context.Background(), item2) + if err != nil && !errors.Is(err, sentinel.ErrQuorumFailed) { + t.Fatalf("unexpected error after replica loss: %v", err) + } +} +tests/hypercache_distmemory_versioning_test.go:17:1: function-length: maximum number of lines per function exceeded; max 75 but got 101 (revive) +func TestDistMemoryVersioningQuorum(t *testing.T) { //nolint:paralleltest + interval := 10 * time.Millisecond + ring := cluster.NewRing(cluster.WithReplication(3)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // three nodes + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + // enable quorum read + write consistency on b1 + b1i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistReplication(3), + backend.WithDistHeartbeat(interval, 0, 0), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + backend.WithDistWriteConsistency(backend.ConsistencyQuorum), + ) + b2i, _ := backend.NewDistMemory(context.TODO(), backend.WithDistMembership(membership, n2), backend.WithDistTransport(transport)) + b3i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + b1, ok := b1i.(*backend.DistMemory) + + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + // Find a deterministic key where ownership ordering is b1,b2,b3 to avoid forwarding complexities. + key := "k" + for i := range 2000 { // brute force + cand := fmt.Sprintf("k%d", i) + + owners := b1.DebugOwners(cand) + if len(owners) == 3 && owners[0] == b1.LocalNodeID() && owners[1] == b2.LocalNodeID() && owners[2] == b3.LocalNodeID() { + key = cand + + break + } + } + + // Write key via primary. + item1 := &cache.Item{Key: key, Value: "v1"} + + err := b1.Set(context.Background(), item1) + if err != nil { + t.Fatalf("initial set: %v", err) + } + + // Simulate a concurrent stale write from another node with lower version (manual injection on b2). + itemStale := &cache.Item{Key: key, Value: "v0", Version: 0, Origin: "zzz"} + b2.DebugDropLocal(key) + b2.DebugInject(itemStale) + + // Read quorum from node3: should observe latest (v1) and repair b2. + it, ok := b3.Get(context.Background(), key) + if !ok { + t.Fatalf("expected read ok") + } + + if it.Value != "v1" { + t.Fatalf("expected value v1, got %v", it.Value) + } + + // Ensure b2 repaired. + if it2, ok2 := b2.Get(context.Background(), key); !ok2 || it2.Value != "v1" { + t.Fatalf("expected repaired value on b2") + } + + // Simulate reduced acks: unregister one replica and perform write requiring quorum (2 of 3). + transport.Unregister(string(n3.ID)) + + item2 := &cache.Item{Key: key, Value: "v2"} + + err = b1.Set(context.Background(), item2) + if err != nil && !errors.Is(err, sentinel.ErrQuorumFailed) { + t.Fatalf("unexpected error after replica loss: %v", err) + } +} +tests/hypercache_distmemory_versioning_test.go:17:1: function-length: maximum number of statements per function exceeded; max 50 but got 51 (revive) +func TestDistMemoryVersioningQuorum(t *testing.T) { //nolint:paralleltest + interval := 10 * time.Millisecond + ring := cluster.NewRing(cluster.WithReplication(3)) + membership := cluster.NewMembership(ring) + transport := backend.NewInProcessTransport() + + // three nodes + n1 := cluster.NewNode("", "n1:0") + n2 := cluster.NewNode("", "n2:0") + n3 := cluster.NewNode("", "n3:0") + + // enable quorum read + write consistency on b1 + b1i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n1), + backend.WithDistTransport(transport), + backend.WithDistReplication(3), + backend.WithDistHeartbeat(interval, 0, 0), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + backend.WithDistWriteConsistency(backend.ConsistencyQuorum), + ) + b2i, _ := backend.NewDistMemory(context.TODO(), backend.WithDistMembership(membership, n2), backend.WithDistTransport(transport)) + b3i, _ := backend.NewDistMemory( + context.TODO(), + backend.WithDistMembership(membership, n3), + backend.WithDistTransport(transport), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + ) + b1, ok := b1i.(*backend.DistMemory) + + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + b3, ok := b3i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b3i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + StopOnCleanup(t, b3) + + transport.Register(b1) + transport.Register(b2) + transport.Register(b3) + + // Find a deterministic key where ownership ordering is b1,b2,b3 to avoid forwarding complexities. + key := "k" + for i := range 2000 { // brute force + cand := fmt.Sprintf("k%d", i) + + owners := b1.DebugOwners(cand) + if len(owners) == 3 && owners[0] == b1.LocalNodeID() && owners[1] == b2.LocalNodeID() && owners[2] == b3.LocalNodeID() { + key = cand + + break + } + } + + // Write key via primary. + item1 := &cache.Item{Key: key, Value: "v1"} + + err := b1.Set(context.Background(), item1) + if err != nil { + t.Fatalf("initial set: %v", err) + } + + // Simulate a concurrent stale write from another node with lower version (manual injection on b2). + itemStale := &cache.Item{Key: key, Value: "v0", Version: 0, Origin: "zzz"} + b2.DebugDropLocal(key) + b2.DebugInject(itemStale) + + // Read quorum from node3: should observe latest (v1) and repair b2. + it, ok := b3.Get(context.Background(), key) + if !ok { + t.Fatalf("expected read ok") + } + + if it.Value != "v1" { + t.Fatalf("expected value v1, got %v", it.Value) + } + + // Ensure b2 repaired. + if it2, ok2 := b2.Get(context.Background(), key); !ok2 || it2.Value != "v1" { + t.Fatalf("expected repaired value on b2") + } + + // Simulate reduced acks: unregister one replica and perform write requiring quorum (2 of 3). + transport.Unregister(string(n3.ID)) + + item2 := &cache.Item{Key: key, Value: "v2"} + + err = b1.Set(context.Background(), item2) + if err != nil && !errors.Is(err, sentinel.ErrQuorumFailed) { + t.Fatalf("unexpected error after replica loss: %v", err) + } +} +tests/hypercache_distmemory_write_quorum_test.go:76:1: function-length: maximum number of lines per function exceeded; max 75 but got 89 (revive) +func TestWriteQuorumFailure(t *testing.T) { + ctx := context.Background() + transport := backend.NewInProcessTransport() + + // Shared ring/membership so ownership is identical across nodes. + ring := cluster.NewRing(cluster.WithReplication(3)) + m := cluster.NewMembership(ring) + m.Upsert(cluster.NewNode("A", "A")) + m.Upsert(cluster.NewNode("B", "B")) + m.Upsert(cluster.NewNode("C", "C")) + + opts := []backend.DistMemoryOption{ + backend.WithDistReplication(3), + backend.WithDistWriteConsistency(backend.ConsistencyAll), + backend.WithDistHintTTL(time.Minute), + backend.WithDistHintReplayInterval(50 * time.Millisecond), + } + + // Create three nodes but only register two with transport to force ALL failure. + na, _ := backend.NewDistMemory( + ctx, + append(opts, backend.WithDistNode("A", "A"), backend.WithDistMembership(m, cluster.NewNode("A", "A")))...) + nb, _ := backend.NewDistMemory( + ctx, + append(opts, backend.WithDistNode("B", "B"), backend.WithDistMembership(m, cluster.NewNode("B", "B")))...) + + nc, _ := backend.NewDistMemory( + ctx, + append(opts, backend.WithDistNode("C", "C"), backend.WithDistMembership(m, cluster.NewNode("C", "C")))...) + + da, ok := any(na).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", na) + } + + db, ok := any(nb).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", nb) + } + + dc, ok := any(nc).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", nc) + } + + StopOnCleanup(t, da) + StopOnCleanup(t, db) + StopOnCleanup(t, dc) + + da.SetTransport(transport) + db.SetTransport(transport) + transport.Register(da) + transport.Register(db) // C intentionally not registered (unreachable) + + // Find a key whose owners include all three nodes (replication=3 ensures this) – just brute force until order stable. + key := "quorum-all-fail" + for i := range 50 { // try some keys to ensure A is primary sometimes; not strictly required + candidate := fmt.Sprintf("quorum-all-fail-%d", i) + + owners := da.Ring().Lookup(candidate) + if len(owners) == 3 && string(owners[0]) == "A" { // prefer A primary for clarity + key = candidate + + break + } + } + + item := &cache.Item{Key: key, Value: "v-fail"} + + err := na.Set(ctx, item) + if !errors.Is(err, sentinel.ErrQuorumFailed) { + // Provide ring owners for debugging. + owners := da.Ring().Lookup(key) + + ids := make([]string, 0, len(owners)) + for _, o := range owners { + ids = append(ids, string(o)) + } + + t.Fatalf("expected ErrQuorumFailed, got %v (owners=%v)", err, ids) + } + + metrics := da.Metrics() + if metrics.WriteQuorumFailures < 1 { + t.Fatalf("expected WriteQuorumFailures >=1, got %d", metrics.WriteQuorumFailures) + } + + if metrics.WriteAttempts < 1 { // should have attempted at least once + t.Fatalf("expected WriteAttempts >=1, got %d", metrics.WriteAttempts) + } +} +tests/hypercache_get_or_set_test.go:15:1: function-length: maximum number of lines per function exceeded; max 75 but got 98 (revive) +func TestHyperCache_GetOrSet(t *testing.T) { + tests := []struct { + name string + key string + value any + expiry time.Duration + expectedValue any + expectedErr error + }{ + { + name: "get or set with valid key and value", + key: "key1", + value: "value1", + expiry: 0, + expectedValue: "value1", + expectedErr: nil, + }, + { + name: "get or set with valid key and value with expiry", + key: "key2", + value: "value2", + expiry: time.Second, + expectedValue: "value2", + expectedErr: nil, + }, + // { + // name: "get or set with empty key", + // key: "", + // value: "value3", + // expiry: 0, + // expectedValue: nil, + // expectedErr: hypercache.ErrInvalidKey, + // }, + { + name: "get or set with nil value", + key: "key4", + value: nil, + expiry: 0, + expectedValue: nil, + expectedErr: sentinel.ErrNilValue, + }, + { + name: "get or set with key that has expired", + key: "key5", + value: "value5", + expiry: time.Millisecond, + expectedValue: nil, + expectedErr: sentinel.ErrKeyExpired, + }, + { + name: "get or set with key that already exists", + key: "key1", + value: "value6", + expiry: 0, + expectedValue: "value1", + expectedErr: nil, + }, + } + cache, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 10) + assert.Nil(t, err) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var ( + val any + err error + ) + + shouldExpire := errors.Is(test.expectedErr, sentinel.ErrKeyExpired) + + val, err = cache.GetOrSet(context.TODO(), test.key, test.value, test.expiry) + if !shouldExpire { + assert.Equal(t, test.expectedErr, err) + } + + if err == nil && !shouldExpire { + assert.Equal(t, test.expectedValue, val) + } + + if shouldExpire { + t.Log("sleeping for 2 Millisecond to allow the key to expire") + time.Sleep(2 * time.Millisecond) + + _, err = cache.GetOrSet(context.TODO(), test.key, test.value, test.expiry) + assert.Equal(t, test.expectedErr, err) + } + + // Check if the value has been set in the cache + if err == nil { + val, ok := cache.Get(context.TODO(), test.key) + assert.True(t, ok) + assert.Equal(t, test.expectedValue, val) + } else { + val, ok := cache.Get(context.TODO(), test.key) + assert.False(t, ok) + assert.Nil(t, val) + } + }) + } +} +tests/hypercache_get_test.go:14:1: cognitive-complexity: function TestHyperCache_Get has cognitive complexity 17 (> max enabled 15) (revive) +func TestHyperCache_Get(t *testing.T) { + tests := []struct { + name string + key string + value any + expiry time.Duration + expectedValue any + expectedErr error + sleep time.Duration + shouldSet bool + }{ + { + name: "get with valid key", + key: "key1", + value: "value1", + expiry: 0, + expectedValue: "value1", + expectedErr: nil, + }, + { + name: "get with valid key and value with expiry", + key: "key2", + value: "value2", + expiry: 5 * time.Second, + expectedValue: "value2", + expectedErr: nil, + }, + // { + // name: "get with empty key", + // key: "", + // value: "value3", + // expiry: 0, + // expectedValue: "", + // expectedErr: hypercache.ErrInvalidKey, + // }, + { + name: "get with expired key", + key: "key4", + value: "value4", + expiry: 1 * time.Second, + expectedValue: nil, + expectedErr: nil, + sleep: 2 * time.Second, + }, + { + name: "get with non-existent key", + key: "key5", + value: "value5", + expiry: 0, + expectedValue: nil, + expectedErr: sentinel.ErrKeyNotFound, + shouldSet: false, + }, + } + cache, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 10) + assert.Nil(t, err) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.shouldSet { + err = cache.Set(context.TODO(), test.key, test.value, test.expiry) + if err != nil { + assert.Equal(t, test.expectedErr, err) + } + + if test.sleep > 0 { + time.Sleep(test.sleep) + } + } + + val, ok := cache.Get(context.TODO(), test.key) + if test.expectedErr != nil || !ok { + assert.False(t, ok) + } else { + assert.True(t, ok) + assert.Equal(t, test.expectedValue, val) + } + }) + } +} +tests/hypercache_get_test.go:14:1: function-length: maximum number of lines per function exceeded; max 75 but got 78 (revive) +func TestHyperCache_Get(t *testing.T) { + tests := []struct { + name string + key string + value any + expiry time.Duration + expectedValue any + expectedErr error + sleep time.Duration + shouldSet bool + }{ + { + name: "get with valid key", + key: "key1", + value: "value1", + expiry: 0, + expectedValue: "value1", + expectedErr: nil, + }, + { + name: "get with valid key and value with expiry", + key: "key2", + value: "value2", + expiry: 5 * time.Second, + expectedValue: "value2", + expectedErr: nil, + }, + // { + // name: "get with empty key", + // key: "", + // value: "value3", + // expiry: 0, + // expectedValue: "", + // expectedErr: hypercache.ErrInvalidKey, + // }, + { + name: "get with expired key", + key: "key4", + value: "value4", + expiry: 1 * time.Second, + expectedValue: nil, + expectedErr: nil, + sleep: 2 * time.Second, + }, + { + name: "get with non-existent key", + key: "key5", + value: "value5", + expiry: 0, + expectedValue: nil, + expectedErr: sentinel.ErrKeyNotFound, + shouldSet: false, + }, + } + cache, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 10) + assert.Nil(t, err) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.shouldSet { + err = cache.Set(context.TODO(), test.key, test.value, test.expiry) + if err != nil { + assert.Equal(t, test.expectedErr, err) + } + + if test.sleep > 0 { + time.Sleep(test.sleep) + } + } + + val, ok := cache.Get(context.TODO(), test.key) + if test.expectedErr != nil || !ok { + assert.False(t, ok) + } else { + assert.True(t, ok) + assert.Equal(t, test.expectedValue, val) + } + }) + } +} +tests/hypercache_http_merkle_test.go:15:1: cognitive-complexity: function TestHTTPFetchMerkle has cognitive complexity 22 (> max enabled 15) (revive) +func TestHTTPFetchMerkle(t *testing.T) { + ctx := context.Background() + + // shared ring/membership + ring := cluster.NewRing(cluster.WithReplication(1)) + membership := cluster.NewMembership(ring) + + // create two nodes with HTTP server enabled (dynamically allocated addresses) + addr1 := AllocatePort(t) + addr2 := AllocatePort(t) + + n1 := cluster.NewNode("", addr1) + + b1i, err := backend.NewDistMemory(ctx, + backend.WithDistMembership(membership, n1), + backend.WithDistNode("n1", addr1), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("b1: %v", err) + } + + n2 := cluster.NewNode("", addr2) + + b2i, err := backend.NewDistMemory(ctx, + backend.WithDistMembership(membership, n2), + backend.WithDistNode("n2", addr2), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("b2: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + + // HTTP transport resolver maps node IDs to http base URLs. + resolver := func(id string) (string, bool) { + switch id { // node IDs same as provided + case "n1": + return "http://" + b1.LocalNodeAddr(), true + case "n2": + return "http://" + b2.LocalNodeAddr(), true + } + + return "", false + } + // 5s transport timeout (was 2s) — under -race the fiber listener can take + // >2s to accept its first request, which made SyncWith time out spuriously. + transport := backend.NewDistHTTPTransport(5*time.Second, resolver) + b1.SetTransport(transport) + b2.SetTransport(transport) + + // ensure membership has both before writes (already upserted in constructors) + // write some keys to b1 only + for i := range 5 { // direct inject to sidestep replication/forwarding complexity + item := &cache.Item{Key: httpKey(i), Value: []byte("v"), Version: uint64(i + 1), Origin: "n1", LastUpdated: time.Now()} + b1.DebugInject(item) + } + + // Poll the HTTP merkle endpoint until it actually responds 200. Under + // -race the fiber listener can take seconds to start accepting requests + // even after Listen() returns; a single-shot Get is racy. + merkleReady := false + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get("http://" + b1.LocalNodeAddr() + "/internal/merkle") + if err == nil { + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + merkleReady = true + + break + } + } + + time.Sleep(50 * time.Millisecond) + } + + if !merkleReady { + t.Fatal("merkle endpoint did not become ready within deadline") + } + + // b2 sync from b1 via HTTP transport + if err := b2.SyncWith(ctx, "n1"); err != nil { + t.Fatalf("sync: %v", err) + } + + // Validate keys present on b2. Allow brief retry to absorb any async tail + // in sync's apply path (each missing key is retried once). + for i := range 5 { + if _, ok := b2.Get(ctx, httpKey(i)); ok { + continue + } + + // One retry: re-sync and check again. + err := b2.SyncWith(ctx, "n1") + if err != nil { + t.Fatalf("re-sync: %v", err) + } + + if _, ok := b2.Get(ctx, httpKey(i)); !ok { + t.Fatalf("missing key %d post-sync", i) + } + } +} +tests/hypercache_http_merkle_test.go:15:1: cyclomatic: function TestHTTPFetchMerkle has cyclomatic complexity 17 (> max enabled 15) (revive) +func TestHTTPFetchMerkle(t *testing.T) { + ctx := context.Background() + + // shared ring/membership + ring := cluster.NewRing(cluster.WithReplication(1)) + membership := cluster.NewMembership(ring) + + // create two nodes with HTTP server enabled (dynamically allocated addresses) + addr1 := AllocatePort(t) + addr2 := AllocatePort(t) + + n1 := cluster.NewNode("", addr1) + + b1i, err := backend.NewDistMemory(ctx, + backend.WithDistMembership(membership, n1), + backend.WithDistNode("n1", addr1), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("b1: %v", err) + } + + n2 := cluster.NewNode("", addr2) + + b2i, err := backend.NewDistMemory(ctx, + backend.WithDistMembership(membership, n2), + backend.WithDistNode("n2", addr2), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("b2: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + + // HTTP transport resolver maps node IDs to http base URLs. + resolver := func(id string) (string, bool) { + switch id { // node IDs same as provided + case "n1": + return "http://" + b1.LocalNodeAddr(), true + case "n2": + return "http://" + b2.LocalNodeAddr(), true + } + + return "", false + } + // 5s transport timeout (was 2s) — under -race the fiber listener can take + // >2s to accept its first request, which made SyncWith time out spuriously. + transport := backend.NewDistHTTPTransport(5*time.Second, resolver) + b1.SetTransport(transport) + b2.SetTransport(transport) + + // ensure membership has both before writes (already upserted in constructors) + // write some keys to b1 only + for i := range 5 { // direct inject to sidestep replication/forwarding complexity + item := &cache.Item{Key: httpKey(i), Value: []byte("v"), Version: uint64(i + 1), Origin: "n1", LastUpdated: time.Now()} + b1.DebugInject(item) + } + + // Poll the HTTP merkle endpoint until it actually responds 200. Under + // -race the fiber listener can take seconds to start accepting requests + // even after Listen() returns; a single-shot Get is racy. + merkleReady := false + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get("http://" + b1.LocalNodeAddr() + "/internal/merkle") + if err == nil { + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + merkleReady = true + + break + } + } + + time.Sleep(50 * time.Millisecond) + } + + if !merkleReady { + t.Fatal("merkle endpoint did not become ready within deadline") + } + + // b2 sync from b1 via HTTP transport + if err := b2.SyncWith(ctx, "n1"); err != nil { + t.Fatalf("sync: %v", err) + } + + // Validate keys present on b2. Allow brief retry to absorb any async tail + // in sync's apply path (each missing key is retried once). + for i := range 5 { + if _, ok := b2.Get(ctx, httpKey(i)); ok { + continue + } + + // One retry: re-sync and check again. + err := b2.SyncWith(ctx, "n1") + if err != nil { + t.Fatalf("re-sync: %v", err) + } + + if _, ok := b2.Get(ctx, httpKey(i)); !ok { + t.Fatalf("missing key %d post-sync", i) + } + } +} +tests/hypercache_http_merkle_test.go:15:1: function-length: maximum number of lines per function exceeded; max 75 but got 116 (revive) +func TestHTTPFetchMerkle(t *testing.T) { + ctx := context.Background() + + // shared ring/membership + ring := cluster.NewRing(cluster.WithReplication(1)) + membership := cluster.NewMembership(ring) + + // create two nodes with HTTP server enabled (dynamically allocated addresses) + addr1 := AllocatePort(t) + addr2 := AllocatePort(t) + + n1 := cluster.NewNode("", addr1) + + b1i, err := backend.NewDistMemory(ctx, + backend.WithDistMembership(membership, n1), + backend.WithDistNode("n1", addr1), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("b1: %v", err) + } + + n2 := cluster.NewNode("", addr2) + + b2i, err := backend.NewDistMemory(ctx, + backend.WithDistMembership(membership, n2), + backend.WithDistNode("n2", addr2), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("b2: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + + // HTTP transport resolver maps node IDs to http base URLs. + resolver := func(id string) (string, bool) { + switch id { // node IDs same as provided + case "n1": + return "http://" + b1.LocalNodeAddr(), true + case "n2": + return "http://" + b2.LocalNodeAddr(), true + } + + return "", false + } + // 5s transport timeout (was 2s) — under -race the fiber listener can take + // >2s to accept its first request, which made SyncWith time out spuriously. + transport := backend.NewDistHTTPTransport(5*time.Second, resolver) + b1.SetTransport(transport) + b2.SetTransport(transport) + + // ensure membership has both before writes (already upserted in constructors) + // write some keys to b1 only + for i := range 5 { // direct inject to sidestep replication/forwarding complexity + item := &cache.Item{Key: httpKey(i), Value: []byte("v"), Version: uint64(i + 1), Origin: "n1", LastUpdated: time.Now()} + b1.DebugInject(item) + } + + // Poll the HTTP merkle endpoint until it actually responds 200. Under + // -race the fiber listener can take seconds to start accepting requests + // even after Listen() returns; a single-shot Get is racy. + merkleReady := false + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get("http://" + b1.LocalNodeAddr() + "/internal/merkle") + if err == nil { + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + merkleReady = true + + break + } + } + + time.Sleep(50 * time.Millisecond) + } + + if !merkleReady { + t.Fatal("merkle endpoint did not become ready within deadline") + } + + // b2 sync from b1 via HTTP transport + if err := b2.SyncWith(ctx, "n1"); err != nil { + t.Fatalf("sync: %v", err) + } + + // Validate keys present on b2. Allow brief retry to absorb any async tail + // in sync's apply path (each missing key is retried once). + for i := range 5 { + if _, ok := b2.Get(ctx, httpKey(i)); ok { + continue + } + + // One retry: re-sync and check again. + err := b2.SyncWith(ctx, "n1") + if err != nil { + t.Fatalf("re-sync: %v", err) + } + + if _, ok := b2.Get(ctx, httpKey(i)); !ok { + t.Fatalf("missing key %d post-sync", i) + } + } +} +tests/hypercache_http_merkle_test.go:15:1: function-length: maximum number of statements per function exceeded; max 50 but got 54 (revive) +func TestHTTPFetchMerkle(t *testing.T) { + ctx := context.Background() + + // shared ring/membership + ring := cluster.NewRing(cluster.WithReplication(1)) + membership := cluster.NewMembership(ring) + + // create two nodes with HTTP server enabled (dynamically allocated addresses) + addr1 := AllocatePort(t) + addr2 := AllocatePort(t) + + n1 := cluster.NewNode("", addr1) + + b1i, err := backend.NewDistMemory(ctx, + backend.WithDistMembership(membership, n1), + backend.WithDistNode("n1", addr1), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("b1: %v", err) + } + + n2 := cluster.NewNode("", addr2) + + b2i, err := backend.NewDistMemory(ctx, + backend.WithDistMembership(membership, n2), + backend.WithDistNode("n2", addr2), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("b2: %v", err) + } + + b1, ok := b1i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b1i to *backend.DistMemory") + } + + b2, ok := b2i.(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast b2i to *backend.DistMemory") + } + + StopOnCleanup(t, b1) + StopOnCleanup(t, b2) + + // HTTP transport resolver maps node IDs to http base URLs. + resolver := func(id string) (string, bool) { + switch id { // node IDs same as provided + case "n1": + return "http://" + b1.LocalNodeAddr(), true + case "n2": + return "http://" + b2.LocalNodeAddr(), true + } + + return "", false + } + // 5s transport timeout (was 2s) — under -race the fiber listener can take + // >2s to accept its first request, which made SyncWith time out spuriously. + transport := backend.NewDistHTTPTransport(5*time.Second, resolver) + b1.SetTransport(transport) + b2.SetTransport(transport) + + // ensure membership has both before writes (already upserted in constructors) + // write some keys to b1 only + for i := range 5 { // direct inject to sidestep replication/forwarding complexity + item := &cache.Item{Key: httpKey(i), Value: []byte("v"), Version: uint64(i + 1), Origin: "n1", LastUpdated: time.Now()} + b1.DebugInject(item) + } + + // Poll the HTTP merkle endpoint until it actually responds 200. Under + // -race the fiber listener can take seconds to start accepting requests + // even after Listen() returns; a single-shot Get is racy. + merkleReady := false + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get("http://" + b1.LocalNodeAddr() + "/internal/merkle") + if err == nil { + _ = resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + merkleReady = true + + break + } + } + + time.Sleep(50 * time.Millisecond) + } + + if !merkleReady { + t.Fatal("merkle endpoint did not become ready within deadline") + } + + // b2 sync from b1 via HTTP transport + if err := b2.SyncWith(ctx, "n1"); err != nil { + t.Fatalf("sync: %v", err) + } + + // Validate keys present on b2. Allow brief retry to absorb any async tail + // in sync's apply path (each missing key is retried once). + for i := range 5 { + if _, ok := b2.Get(ctx, httpKey(i)); ok { + continue + } + + // One retry: re-sync and check again. + err := b2.SyncWith(ctx, "n1") + if err != nil { + t.Fatalf("re-sync: %v", err) + } + + if _, ok := b2.Get(ctx, httpKey(i)); !ok { + t.Fatalf("missing key %d post-sync", i) + } + } +} +tests/hypercache_mgmt_dist_test.go:18:1: cognitive-complexity: function TestManagementHTTPDistMemory has cognitive complexity 18 (> max enabled 15) (revive) +func TestManagementHTTPDistMemory(t *testing.T) { //nolint:paralleltest + cfg, err := hypercache.NewConfig[backend.DistMemory](constants.DistMemoryBackend) + if err != nil { + t.Fatalf("NewConfig: %v", err) + } + + cfg.HyperCacheOptions = append(cfg.HyperCacheOptions, + hypercache.WithManagementHTTP[backend.DistMemory]("127.0.0.1:0"), // ephemeral port + ) + cfg.DistMemoryOptions = []backend.DistMemoryOption{ + backend.WithDistReplication(1), + backend.WithDistVirtualNodes(32), + backend.WithDistNode("test-node", "local"), + } + + hc, err := hypercache.New(context.Background(), hypercache.GetDefaultManager(), cfg) + if err != nil { + t.Fatalf("new dist hypercache: %v", err) + } + + defer func() { _ = hc.Stop(context.Background()) }() + + baseURL := waitForMgmt(t, hc) + + // Insert a key to exercise owners endpoint. + err = hc.Set(context.Background(), "alpha", "value", 0) + if err != nil { + // not fatal for owners shape but should succeed given replication=1 + t.Fatalf("set alpha: %v", err) + } + + // /config should include replication + virtualNodesPerNode + configBody := getJSON(t, baseURL+"/config") + if _, ok := configBody["replication"]; !ok { + t.Errorf("/config missing replication") + } + + if vnp, ok := configBody["virtualNodesPerNode"]; !ok || vnp == nil { + t.Errorf("/config missing virtualNodesPerNode") + } + + // /dist/metrics basic shape + metricsBody := getJSON(t, baseURL+"/dist/metrics") + if _, ok := metricsBody["ForwardGet"]; !ok { // one exported field + // could be 404 if backend unsupported (should not be here) + if e, hasErr := metricsBody[constants.ErrorLabel]; hasErr { + t.Fatalf("/dist/metrics returned error: %v", e) + } + + // else fail + t.Errorf("/dist/metrics missing ForwardGet field") + } + + // /dist/owners + ownersBody := getJSON(t, baseURL+"/dist/owners?key=alpha") + if _, ok := ownersBody["owners"]; !ok { + if e, hasErr := ownersBody[constants.ErrorLabel]; hasErr { + t.Fatalf("/dist/owners returned error: %v", e) + } + + t.Errorf("/dist/owners missing owners field") + } + + // /cluster/members + membersBody := getJSON(t, baseURL+"/cluster/members") + if _, ok := membersBody["members"]; !ok { + if e, hasErr := membersBody[constants.ErrorLabel]; hasErr { + t.Fatalf("/cluster/members returned error: %v", e) + } + + t.Errorf("/cluster/members missing members field") + } + + // /cluster/ring + ringBody := getJSON(t, baseURL+"/cluster/ring") + if _, ok := ringBody["vnodes"]; !ok { + if e, hasErr := ringBody[constants.ErrorLabel]; hasErr { + t.Fatalf("/cluster/ring returned error: %v", e) + } + + t.Errorf("/cluster/ring missing vnodes field") + } +} +tests/hypercache_mgmt_dist_test.go:18:1: function-length: maximum number of lines per function exceeded; max 75 but got 81 (revive) +func TestManagementHTTPDistMemory(t *testing.T) { //nolint:paralleltest + cfg, err := hypercache.NewConfig[backend.DistMemory](constants.DistMemoryBackend) + if err != nil { + t.Fatalf("NewConfig: %v", err) + } + + cfg.HyperCacheOptions = append(cfg.HyperCacheOptions, + hypercache.WithManagementHTTP[backend.DistMemory]("127.0.0.1:0"), // ephemeral port + ) + cfg.DistMemoryOptions = []backend.DistMemoryOption{ + backend.WithDistReplication(1), + backend.WithDistVirtualNodes(32), + backend.WithDistNode("test-node", "local"), + } + + hc, err := hypercache.New(context.Background(), hypercache.GetDefaultManager(), cfg) + if err != nil { + t.Fatalf("new dist hypercache: %v", err) + } + + defer func() { _ = hc.Stop(context.Background()) }() + + baseURL := waitForMgmt(t, hc) + + // Insert a key to exercise owners endpoint. + err = hc.Set(context.Background(), "alpha", "value", 0) + if err != nil { + // not fatal for owners shape but should succeed given replication=1 + t.Fatalf("set alpha: %v", err) + } + + // /config should include replication + virtualNodesPerNode + configBody := getJSON(t, baseURL+"/config") + if _, ok := configBody["replication"]; !ok { + t.Errorf("/config missing replication") + } + + if vnp, ok := configBody["virtualNodesPerNode"]; !ok || vnp == nil { + t.Errorf("/config missing virtualNodesPerNode") + } + + // /dist/metrics basic shape + metricsBody := getJSON(t, baseURL+"/dist/metrics") + if _, ok := metricsBody["ForwardGet"]; !ok { // one exported field + // could be 404 if backend unsupported (should not be here) + if e, hasErr := metricsBody[constants.ErrorLabel]; hasErr { + t.Fatalf("/dist/metrics returned error: %v", e) + } + + // else fail + t.Errorf("/dist/metrics missing ForwardGet field") + } + + // /dist/owners + ownersBody := getJSON(t, baseURL+"/dist/owners?key=alpha") + if _, ok := ownersBody["owners"]; !ok { + if e, hasErr := ownersBody[constants.ErrorLabel]; hasErr { + t.Fatalf("/dist/owners returned error: %v", e) + } + + t.Errorf("/dist/owners missing owners field") + } + + // /cluster/members + membersBody := getJSON(t, baseURL+"/cluster/members") + if _, ok := membersBody["members"]; !ok { + if e, hasErr := membersBody[constants.ErrorLabel]; hasErr { + t.Fatalf("/cluster/members returned error: %v", e) + } + + t.Errorf("/cluster/members missing members field") + } + + // /cluster/ring + ringBody := getJSON(t, baseURL+"/cluster/ring") + if _, ok := ringBody["vnodes"]; !ok { + if e, hasErr := ringBody[constants.ErrorLabel]; hasErr { + t.Fatalf("/cluster/ring returned error: %v", e) + } + + t.Errorf("/cluster/ring missing vnodes field") + } +} +tests/integration/dist_phase1_test.go:33:1: cognitive-complexity: function TestDistPhase1BasicQuorum has cognitive complexity 16 (> max enabled 15) (revive) +func TestDistPhase1BasicQuorum(t *testing.T) { + ctx := context.Background() + + addrA := allocatePort(t) + addrB := allocatePort(t) + addrC := allocatePort(t) + + // create three nodes; we'll stop C's HTTP after start to simulate outage then restart + makeNode := func(id, addr string, seeds []string) *backend.DistMemory { + bm, err := backend.NewDistMemory(ctx, + backend.WithDistNode(id, addr), + backend.WithDistSeeds(seeds), + backend.WithDistReplication(3), + backend.WithDistVirtualNodes(32), + backend.WithDistHintReplayInterval(200*time.Millisecond), + backend.WithDistHintTTL(5*time.Second), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + backend.WithDistWriteConsistency(backend.ConsistencyQuorum), + ) + if err != nil { + t.Fatalf("new dist memory: %v", err) + } + + bk, ok := bm.(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", bm) + } + + return bk + } + + nodeA := makeNode("A", addrA, []string{addrB, addrC}) + nodeB := makeNode("B", addrB, []string{addrA, addrC}) + nodeC := makeNode("C", addrC, []string{addrA, addrB}) + // defer cleanup of A and B + defer func() { _ = nodeA.Stop(ctx); _ = nodeB.Stop(ctx) }() + + // allow some time for ring initialization + time.Sleep(200 * time.Millisecond) + + // Perform a write expecting replication across all three nodes + item := &cache.Item{Key: "k1", Value: []byte("v1"), Expiration: 0, Version: 1, Origin: "A", LastUpdated: time.Now()} + + err := nodeA.Set(ctx, item) + if err != nil { + t.Fatalf("set: %v", err) + } + + // Quorum read from B should succeed (value may be []byte, string, or json.RawMessage) + if got, ok := nodeB.Get(ctx, "k1"); !ok { + t.Fatalf("expected quorum read via B: not found") + } else { + assertValue(t, got.Value) + } + + // Basic propagation check loop (give replication a moment) + defer func() { _ = nodeC.Stop(ctx) }() + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if it, ok := nodeC.Get(ctx, "k1"); ok { + if valueOK(it.Value) { + goto Done + } + } + + time.Sleep(100 * time.Millisecond) + } + + if it, ok := nodeC.Get(ctx, "k1"); !ok { + // Not fatal yet; we only created scaffolding – mark skip for now. + t.Skipf("hint replay not yet observable; will be validated after full wiring (missing item)") + } else { + if !valueOK(it.Value) { + t.Skipf("value mismatch after wait") + } + } + +Done: +} +tests/integration/dist_phase1_test.go:33:1: function-length: maximum number of lines per function exceeded; max 75 but got 78 (revive) +func TestDistPhase1BasicQuorum(t *testing.T) { + ctx := context.Background() + + addrA := allocatePort(t) + addrB := allocatePort(t) + addrC := allocatePort(t) + + // create three nodes; we'll stop C's HTTP after start to simulate outage then restart + makeNode := func(id, addr string, seeds []string) *backend.DistMemory { + bm, err := backend.NewDistMemory(ctx, + backend.WithDistNode(id, addr), + backend.WithDistSeeds(seeds), + backend.WithDistReplication(3), + backend.WithDistVirtualNodes(32), + backend.WithDistHintReplayInterval(200*time.Millisecond), + backend.WithDistHintTTL(5*time.Second), + backend.WithDistReadConsistency(backend.ConsistencyQuorum), + backend.WithDistWriteConsistency(backend.ConsistencyQuorum), + ) + if err != nil { + t.Fatalf("new dist memory: %v", err) + } + + bk, ok := bm.(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", bm) + } + + return bk + } + + nodeA := makeNode("A", addrA, []string{addrB, addrC}) + nodeB := makeNode("B", addrB, []string{addrA, addrC}) + nodeC := makeNode("C", addrC, []string{addrA, addrB}) + // defer cleanup of A and B + defer func() { _ = nodeA.Stop(ctx); _ = nodeB.Stop(ctx) }() + + // allow some time for ring initialization + time.Sleep(200 * time.Millisecond) + + // Perform a write expecting replication across all three nodes + item := &cache.Item{Key: "k1", Value: []byte("v1"), Expiration: 0, Version: 1, Origin: "A", LastUpdated: time.Now()} + + err := nodeA.Set(ctx, item) + if err != nil { + t.Fatalf("set: %v", err) + } + + // Quorum read from B should succeed (value may be []byte, string, or json.RawMessage) + if got, ok := nodeB.Get(ctx, "k1"); !ok { + t.Fatalf("expected quorum read via B: not found") + } else { + assertValue(t, got.Value) + } + + // Basic propagation check loop (give replication a moment) + defer func() { _ = nodeC.Stop(ctx) }() + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if it, ok := nodeC.Get(ctx, "k1"); ok { + if valueOK(it.Value) { + goto Done + } + } + + time.Sleep(100 * time.Millisecond) + } + + if it, ok := nodeC.Get(ctx, "k1"); !ok { + // Not fatal yet; we only created scaffolding – mark skip for now. + t.Skipf("hint replay not yet observable; will be validated after full wiring (missing item)") + } else { + if !valueOK(it.Value) { + t.Skipf("value mismatch after wait") + } + } + +Done: +} +tests/integration/dist_phase1_test.go:115:1: cognitive-complexity: function valueOK has cognitive complexity 42 (> max enabled 15) (revive) +func valueOK(v any) bool { //nolint:ireturn + switch x := v.(type) { + case []byte: + if string(x) == "v1" { + return true + } + + if s := string(x); s == "djE=" { // base64 of v1 + if b, err := base64.StdEncoding.DecodeString(s); err == nil && string(b) == "v1" { + return true + } + } + + return false + + case string: + if x == "v1" { + return true + } + + if x == "djE=" { // base64 form + if b, err := base64.StdEncoding.DecodeString(x); err == nil && string(b) == "v1" { + return true + } + } + + return false + + case json.RawMessage: + // could be "v1" or base64 inside quotes + if len(x) == 0 { + return false + } + + // try as string literal + var s string + + err := json.Unmarshal(x, &s) + if err == nil { + if s == "v1" { + return true + } + + if s == "djE=" { + if b, err2 := base64.StdEncoding.DecodeString(s); err2 == nil && string(b) == "v1" { + return true + } + } + } + + // fall back to raw compare + return string(x) == "v1" || string(x) == "\"v1\"" + + default: + s := fmt.Sprintf("%v", x) + if s == "v1" || s == "\"v1\"" { + return true + } + + if s == "djE=" { + if b, err := base64.StdEncoding.DecodeString(s); err == nil && string(b) == "v1" { + return true + } + } + + return false + } +} +tests/integration/dist_phase1_test.go:115:1: cyclomatic: function valueOK has cyclomatic complexity 25 (> max enabled 15) (revive) +func valueOK(v any) bool { //nolint:ireturn + switch x := v.(type) { + case []byte: + if string(x) == "v1" { + return true + } + + if s := string(x); s == "djE=" { // base64 of v1 + if b, err := base64.StdEncoding.DecodeString(s); err == nil && string(b) == "v1" { + return true + } + } + + return false + + case string: + if x == "v1" { + return true + } + + if x == "djE=" { // base64 form + if b, err := base64.StdEncoding.DecodeString(x); err == nil && string(b) == "v1" { + return true + } + } + + return false + + case json.RawMessage: + // could be "v1" or base64 inside quotes + if len(x) == 0 { + return false + } + + // try as string literal + var s string + + err := json.Unmarshal(x, &s) + if err == nil { + if s == "v1" { + return true + } + + if s == "djE=" { + if b, err2 := base64.StdEncoding.DecodeString(s); err2 == nil && string(b) == "v1" { + return true + } + } + } + + // fall back to raw compare + return string(x) == "v1" || string(x) == "\"v1\"" + + default: + s := fmt.Sprintf("%v", x) + if s == "v1" || s == "\"v1\"" { + return true + } + + if s == "djE=" { + if b, err := base64.StdEncoding.DecodeString(s); err == nil && string(b) == "v1" { + return true + } + } + + return false + } +} +tests/integration/dist_rebalance_test.go:17:1: function-length: maximum number of lines per function exceeded; max 75 but got 93 (revive) +func TestDistRebalanceJoin(t *testing.T) { + ctx := context.Background() + + // Initial cluster: 2 nodes. + addrA := allocatePort(t) + addrB := allocatePort(t) + + nodeA := mustDistNode( + ctx, + t, + "A", + addrA, + []string{addrB}, + backend.WithDistReplication(2), + backend.WithDistVirtualNodes(32), + backend.WithDistRebalanceInterval(100*time.Millisecond), + ) + + nodeB := mustDistNode( + ctx, + t, + "B", + addrB, + []string{addrA}, + backend.WithDistReplication(2), + backend.WithDistVirtualNodes(32), + backend.WithDistRebalanceInterval(100*time.Millisecond), + ) + defer func() { _ = nodeA.Stop(ctx); _ = nodeB.Stop(ctx) }() + + // Write a spread of keys via A. + totalKeys := 300 + for i := range totalKeys { + k := cacheKey(i) + + it := &cache.Item{Key: k, Value: []byte("v"), Version: 1, Origin: "A", LastUpdated: time.Now()} + + err := nodeA.Set(ctx, it) + if err != nil { + t.Fatalf("set %s: %v", k, err) + } + } + + time.Sleep(200 * time.Millisecond) // allow initial replication + + // Capture ownership counts before join. + skeys := sampleKeys(totalKeys) + + _ = ownedPrimaryCount(nodeA, skeys) // baseline (unused currently) + _ = ownedPrimaryCount(nodeB, skeys) + + // Add third node C. + addrC := allocatePort(t) + + nodeC := mustDistNode( + ctx, + t, + "C", + addrC, + []string{addrA, addrB}, + backend.WithDistReplication(2), + backend.WithDistVirtualNodes(32), + backend.WithDistRebalanceInterval(100*time.Millisecond), + ) + defer func() { _ = nodeC.Stop(ctx) }() + + // Manually inject C into A and B membership (simulating gossip propagation delay that doesn't exist yet). + nodeA.AddPeer(addrC) + nodeB.AddPeer(addrC) + + // Allow membership to propagate + several rebalance ticks. + time.Sleep(1200 * time.Millisecond) + + // Post-join ownership counts (sampled locally using isOwner logic via Get + Metrics ring lookup indirectly). + postOwnedA := ownedPrimaryCount(nodeA, skeys) + postOwnedB := ownedPrimaryCount(nodeB, skeys) + postOwnedC := ownedPrimaryCount(nodeC, skeys) + + // Basic sanity: new node should now own > 0 keys. + if postOwnedC == 0 { + t.Fatalf("expected node C to own some keys after rebalancing") + } + + // Distribution variance check: ensure no node has > 80% of sample (initial naive rebalance heuristic). + maxAllowed := int(float64(totalKeys) * 0.80) + if postOwnedA > maxAllowed || postOwnedB > maxAllowed || postOwnedC > maxAllowed { + t.Fatalf("ownership still highly skewed: A=%d B=%d C=%d", postOwnedA, postOwnedB, postOwnedC) + } + + // Rebalance metrics should show migrations (keys forwarded off old primaries) across cluster. + migrated := nodeA.Metrics().RebalancedKeys + nodeB.Metrics().RebalancedKeys + nodeC.Metrics().RebalancedKeys + if migrated == 0 { + t.Fatalf("expected some rebalanced keys (total migrated=0)") + } +} +tests/management_http_test.go:19:1: function-length: maximum number of lines per function exceeded; max 75 but got 81 (revive) +func TestManagementHTTP_BasicEndpoints(t *testing.T) { + cfg, err := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) + if err != nil { + t.Fatalf("NewConfig: %v", err) + } + + cfg.HyperCacheOptions = append(cfg.HyperCacheOptions, + hypercache.WithEvictionInterval[backend.InMemory](0), + hypercache.WithManagementHTTP[backend.InMemory]("127.0.0.1:0"), + ) + + ctx := context.Background() + hc, err := hypercache.New(ctx, hypercache.GetDefaultManager(), cfg) + assert.Nil(t, err) + + defer hc.Stop(ctx) + + // Wait for the management HTTP listener to come up. The race detector + // can push listener startup well past the original 30 ms; poll with a + // generous deadline instead. + var addr string + + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + addr = hc.ManagementHTTPAddress() + if addr != "" { + break + } + + time.Sleep(10 * time.Millisecond) + } + + if addr == "" { + t.Fatal("management HTTP listener did not bind within deadline") + } + + client := &http.Client{Timeout: 5 * time.Second} + + // /health + resp, err := client.Get("http://" + addr + "/health") + if err != nil { + t.Fatalf("GET /health: %v", err) + } + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = resp.Body.Close() + + // /stats + resp, err = client.Get("http://" + addr + "/stats") + if err != nil { + t.Fatalf("GET /stats: %v", err) + } + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var statsBody map[string]any + + dec := json.NewDecoder(resp.Body) + + err = dec.Decode(&statsBody) + assert.NoError(t, err) + + _ = resp.Body.Close() + + // /config + resp, err = client.Get("http://" + addr + "/config") + if err != nil { + t.Fatalf("GET /config: %v", err) + } + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var cfgBody map[string]any + + dec = json.NewDecoder(resp.Body) + _ = dec.Decode(&cfgBody) + _ = resp.Body.Close() + + assert.True(t, len(cfgBody) > 0) + + assert.True(t, cfgBody["evictionAlgorithm"] != nil) +} +tests/merkle_sync_test.go:13:1: function-length: maximum number of lines per function exceeded; max 75 but got 76 (revive) +func TestMerkleSyncConvergence(t *testing.T) { + ctx := context.Background() + transport := backend.NewInProcessTransport() + + bA, err := backend.NewDistMemory(ctx, + backend.WithDistNode("A", AllocatePort(t)), + backend.WithDistReplication(1), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("new dist memory A: %v", err) + } + + dmA, ok := any(bA).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", bA) + } + + StopOnCleanup(t, dmA) + + bB, err := backend.NewDistMemory(ctx, + backend.WithDistNode("B", AllocatePort(t)), + backend.WithDistReplication(1), + backend.WithDistMerkleChunkSize(2), + ) + if err != nil { + t.Fatalf("new dist memory B: %v", err) + } + + dmB, ok := any(bB).(*backend.DistMemory) + if !ok { + t.Fatalf("expected *backend.DistMemory, got %T", bB) + } + + StopOnCleanup(t, dmB) + + dmA.SetTransport(transport) + dmB.SetTransport(transport) + + // register for in-process lookups + transport.Register(dmA) + transport.Register(dmB) + + // inject divergent data (A has extra/newer) + for i := range 5 { + it := &cache.Item{Key: keyf("k", i), Value: []byte("vA"), Version: uint64(i + 1), Origin: "A", LastUpdated: time.Now()} + dmA.DebugInject(it) + } + + // B shares only first 2 keys older versions + for i := range 2 { + it := &cache.Item{Key: keyf("k", i), Value: []byte("old"), Version: uint64(i), Origin: "B", LastUpdated: time.Now()} + dmB.DebugInject(it) + } + + // Run sync B->A to pull newer + if err := dmB.SyncWith(ctx, string(dmA.LocalNodeID())); err != nil { + // HTTP transport fetch merkle unsupported; we rely on in-process + if testing.Verbose() { + t.Logf("sync error: %v", err) + } + } + + // Validate B now has all 5 keys with correct versions (>= A's) + for i := range 5 { + k := keyf("k", i) + itA, _ := dmA.Get(ctx, k) + + itB, _ := dmB.Get(ctx, k) + if itA == nil || itB == nil { + t.Fatalf("missing key %s after sync", k) + } + + if itB.Version < itA.Version { + t.Fatalf("expected B version >= A version for %s", k) + } + } +} +176 issues: +* cyclop: 7 +* dupl: 2 +* err113: 1 +* errcheck: 10 +* forcetypeassert: 3 +* funlen: 9 +* gocognit: 2 +* goconst: 19 +* gocritic: 2 +* gosec: 8 +* noctx: 5 +* noinlineerr: 3 +* paralleltest: 50 +* prealloc: 3 +* predeclared: 2 +* revive: 50 diff --git a/pkg/backend/dist_memory.go b/pkg/backend/dist_memory.go index 2a85fad..2a7992d 100644 --- a/pkg/backend/dist_memory.go +++ b/pkg/backend/dist_memory.go @@ -15,6 +15,8 @@ import ( "sync/atomic" "time" + "github.com/hyp3rd/ewrap" + "github.com/hyp3rd/hypercache/internal/cluster" "github.com/hyp3rd/hypercache/internal/sentinel" cache "github.com/hyp3rd/hypercache/pkg/cache/v2" @@ -34,7 +36,7 @@ const ( shiftPerByte = 8 // bit shift per byte when encoding uint64 ) -var errNoTransport = errors.New("no transport") +var errNoTransport = ewrap.New("no transport") // DistMemory is a sharded in-process distributed-like backend. It simulates // distribution by consistent hashing across a fixed set of in-memory shards. @@ -141,7 +143,7 @@ const ( defaultRebalanceMaxConcurrent = 2 ) -var errUnexpectedBackendType = errors.New("backend: unexpected backend type") // stable error (no dynamic wrapping needed) +var errUnexpectedBackendType = ewrap.New("backend: unexpected backend type") // stable error (no dynamic wrapping needed) // distTransportSlot wraps a DistTransport interface value so it can be stored // in atomic.Pointer (atomic.Pointer requires a concrete pointer type). diff --git a/pkg/cache/v2/cmap.go b/pkg/cache/v2/cmap.go index a4c4173..b41ff56 100644 --- a/pkg/cache/v2/cmap.go +++ b/pkg/cache/v2/cmap.go @@ -23,6 +23,7 @@ package v2 import ( "sync" + "sync/atomic" "time" ) @@ -40,10 +41,16 @@ type ConcurrentMap struct { } // ConcurrentMapShard is a "thread" safe string to `*cache.Item` map shard. +// +// count tracks len(items) under the same lock as items, but as an atomic so +// Count() (sum of 32 shard counts) can read it without acquiring any locks. +// This eliminates the lock-storm in the eviction inner loop's per-iteration +// Count() check. type ConcurrentMapShard struct { sync.RWMutex items map[string]*Item + count atomic.Int64 } // New creates a new concurrent map. @@ -73,23 +80,10 @@ func (cm *ConcurrentMap) GetShard(key string) *ConcurrentMapShard { } // getShardIndex calculates the shard index for the given key. +// Uses the shared Hash function so other packages (eviction.Sharded) +// route the same key to the same logical shard. func getShardIndex(key string) uint32 { - // Inline FNV-1a 32-bit hashing to avoid allocations. - const ( - fnvOffset32 = 2166136261 - fnvPrime32 = 16777619 - ) - - var sum uint32 = fnvOffset32 - - for i := range key { // Go 1.22+ integer range over string indices - sum ^= uint32(key[i]) - - sum *= fnvPrime32 - } - - // Calculate the shard index using a bitwise AND operation. - return sum & (ShardCount32 - 1) + return Hash(key) & (ShardCount32 - 1) } // Set sets the given value under the specified key. @@ -97,6 +91,10 @@ func (cm *ConcurrentMap) Set(key string, value *Item) { shard := cm.GetShard(key) shard.Lock() + if _, existed := shard.items[key]; !existed { + shard.count.Add(1) + } + shard.items[key] = value shard.Unlock() } @@ -177,6 +175,7 @@ func (cm *ConcurrentMap) Pop(key string) (*Item, bool) { } delete(shard.items, key) + shard.count.Add(-1) shard.Unlock() return item, ok @@ -274,7 +273,12 @@ func (cm *ConcurrentMap) Remove(key string) { // Get map shard. shard := cm.GetShard(key) shard.Lock() - delete(shard.items, key) + + if _, existed := shard.items[key]; existed { + delete(shard.items, key) + shard.count.Add(-1) + } + shard.Unlock() } @@ -285,20 +289,24 @@ func (cm *ConcurrentMap) Clear() { shard.Lock() shard.items = make(map[string]*Item) + shard.count.Store(0) shard.Unlock() } } // Count returns the number of items in the map. +// +// Lock-free: each shard maintains its cardinality as an atomic.Int64 +// (mutated under the shard lock alongside items). Count() is the sum of +// the 32 atomics. The previous implementation walked all 32 shard RLocks +// per call, which serialized with writers and was the dominant cost in +// the eviction inner loop's `for backend.Count(ctx) > backend.Capacity()`. func (cm *ConcurrentMap) Count() int { - count := 0 + var total int64 for _, shard := range cm.shards { - shard.RLock() - - count += len(shard.items) - shard.RUnlock() + total += shard.count.Load() } - return count + return int(total) } diff --git a/pkg/cache/v2/cmap_test.go b/pkg/cache/v2/cmap_test.go index f31bfe6..91311e9 100644 --- a/pkg/cache/v2/cmap_test.go +++ b/pkg/cache/v2/cmap_test.go @@ -192,6 +192,44 @@ func TestCount(t *testing.T) { } } +// TestCount_SetExistingKeyDoesNotIncrement guards the per-shard atomic Count +// implementation against the obvious mistake: re-Setting an existing key must +// not increment the shard counter. Without the existence check, Count drifts +// every time a key is overwritten. +func TestCount_SetExistingKeyDoesNotIncrement(t *testing.T) { + cm := New() + item := &Item{Value: "v", Expiration: time.Hour} + + cm.Set("key1", item) + + if got := cm.Count(); got != 1 { + t.Fatalf("after first Set: Count = %d, want 1", got) + } + + // Overwrite the same key 100x; Count must stay at 1. + for range 100 { + cm.Set("key1", item) + } + + if got := cm.Count(); got != 1 { + t.Errorf("after 100 overwrites: Count = %d, want 1", got) + } + + // Removing a non-existent key must not decrement. + cm.Remove("never_set") + + if got := cm.Count(); got != 1 { + t.Errorf("after Remove(missing): Count = %d, want 1", got) + } + + // Clear resets all shards to 0. + cm.Clear() + + if got := cm.Count(); got != 0 { + t.Errorf("after Clear: Count = %d, want 0", got) + } +} + func TestIterBuffered(t *testing.T) { cm := New() items := map[string]*Item{ diff --git a/pkg/cache/v2/hash.go b/pkg/cache/v2/hash.go new file mode 100644 index 0000000..b5bb8b7 --- /dev/null +++ b/pkg/cache/v2/hash.go @@ -0,0 +1,26 @@ +package v2 + +// Hash returns a 32-bit FNV-1a hash of key. Exported so other packages (e.g. +// the sharded eviction wrapper) can use the same hash as ConcurrentMap and +// route the same key to the same logical shard — preserving cache-locality +// when the data shard and eviction shard have different counts. +// +// Step 4 of the modernization will replace this with xxhash.Sum64String for +// faster hashing and better distribution. Callers should treat the return +// value as opaque. +func Hash(key string) uint32 { + const ( + fnvOffset32 = 2166136261 + fnvPrime32 = 16777619 + ) + + var sum uint32 = fnvOffset32 + + for i := range key { // Go 1.22+ integer range over string indices + sum ^= uint32(key[i]) + + sum *= fnvPrime32 + } + + return sum +} diff --git a/pkg/eviction/sharded.go b/pkg/eviction/sharded.go new file mode 100644 index 0000000..71a753e --- /dev/null +++ b/pkg/eviction/sharded.go @@ -0,0 +1,104 @@ +package eviction + +import ( + "sync/atomic" + + "github.com/hyp3rd/ewrap" + + "github.com/hyp3rd/hypercache/internal/sentinel" + cachev2 "github.com/hyp3rd/hypercache/pkg/cache/v2" +) + +// Sharded wraps shardCount independent IAlgorithm instances and routes each +// key to one shard via the same hash that pkg/cache/v2.ConcurrentMap uses. +// This eliminates the global mutex contention of single-instance algorithms +// (LRU/LFU/Clock) by replacing it with shardCount distinct mutexes. +// +// Behavior change vs unsharded: items are evicted within their own shard, not +// in strict global LRU/LFU order. Total capacity is honored (sum of per-shard +// capacities). For users that need strict global-order eviction, construct +// the algorithm directly (or use shardCount=1 via WithEvictionShardCount(1)). +type Sharded struct { + shards []IAlgorithm + mask uint32 + evictCursor atomic.Uint32 // round-robin cursor for Evict +} + +// NewSharded constructs a Sharded eviction wrapper. shardCount must be a +// power of two so the hash can be masked with `& (shardCount-1)`. Each +// underlying shard receives capacity ceil(totalCapacity / shardCount). +// +// algorithmName is one of the registered algorithms (lru, lfu, clock, +// cawolfu). Each shard is an independent instance; they do not share state. +func NewSharded(algorithmName string, totalCapacity, shardCount int) (*Sharded, error) { + if shardCount <= 0 || shardCount&(shardCount-1) != 0 { + return nil, ewrap.Wrapf(sentinel.ErrInvalidCapacity, "shardCount %d must be a positive power of two", shardCount) + } + + if totalCapacity < 0 { + return nil, sentinel.ErrInvalidCapacity + } + + perShard := (totalCapacity + shardCount - 1) / shardCount + + shards := make([]IAlgorithm, shardCount) + + for i := range shardCount { + shard, err := NewEvictionAlgorithm(algorithmName, perShard) + if err != nil { + return nil, ewrap.Wrapf(err, "shard %d", i) + } + + shards[i] = shard + } + + return &Sharded{ + shards: shards, + mask: uint32(shardCount - 1), //nolint:gosec // shardCount validated power-of-two above + }, nil +} + +// Set forwards to the key's shard. +func (s *Sharded) Set(key string, value any) { + s.shardFor(key).Set(key, value) +} + +// Get forwards to the key's shard. +func (s *Sharded) Get(key string) (any, bool) { + return s.shardFor(key).Get(key) +} + +// Delete forwards to the key's shard. +func (s *Sharded) Delete(key string) { + s.shardFor(key).Delete(key) +} + +// Evict picks a shard via round-robin and evicts from it. If that shard is +// empty, the next shard is tried until a non-empty one is found or all +// shards have been visited. Returns ("", false) only when all shards are +// empty. +// +// Round-robin distributes eviction pressure across shards rather than +// always hitting the first non-empty one — relevant when one shard sees +// disproportionately fewer Set calls than others. +func (s *Sharded) Evict() (string, bool) { + n := uint32(len(s.shards)) //nolint:gosec // len(s.shards) bounded by shardCount param + + start := s.evictCursor.Add(1) - 1 + + for i := range n { + idx := (start + i) & s.mask + if key, ok := s.shards[idx].Evict(); ok { + return key, true + } + } + + return "", false +} + +// shardFor returns the shard responsible for key. Uses the same hash function +// as pkg/cache/v2.ConcurrentMap so a key's data shard and eviction shard map +// to the same logical position (cache-locality on Set). +func (s *Sharded) shardFor(key string) IAlgorithm { + return s.shards[cachev2.Hash(key)&s.mask] +} diff --git a/pkg/eviction/sharded_test.go b/pkg/eviction/sharded_test.go new file mode 100644 index 0000000..63786ff --- /dev/null +++ b/pkg/eviction/sharded_test.go @@ -0,0 +1,205 @@ +package eviction + +import ( + "errors" + "strconv" + "sync" + "testing" + + "github.com/hyp3rd/hypercache/internal/sentinel" + cachev2 "github.com/hyp3rd/hypercache/pkg/cache/v2" +) + +func TestSharded_ConstructorRejectsBadShardCount(t *testing.T) { + t.Parallel() + + cases := []int{0, -1, 3, 5, 7, 17} + for _, n := range cases { + _, err := NewSharded("lru", 100, n) + if err == nil { + t.Errorf("shardCount=%d: expected error, got nil", n) + } + } + + // Valid power-of-two sizes succeed. + for _, n := range []int{1, 2, 4, 8, 16, 32, 64} { + s, err := NewSharded("lru", 100, n) + if err != nil { + t.Errorf("shardCount=%d: unexpected error: %v", n, err) + } + + if s == nil { + t.Errorf("shardCount=%d: nil Sharded", n) + } + } +} + +func TestSharded_RejectsNegativeCapacity(t *testing.T) { + t.Parallel() + + _, err := NewSharded("lru", -1, 4) + if !errors.Is(err, sentinel.ErrInvalidCapacity) { + t.Errorf("expected ErrInvalidCapacity, got %v", err) + } +} + +func TestSharded_RejectsUnknownAlgorithm(t *testing.T) { + t.Parallel() + + _, err := NewSharded("nonexistent_algo", 100, 4) + if err == nil { + t.Errorf("expected error for unknown algorithm name") + } +} + +// TestSharded_RoutesByConcurrentMapHash verifies the cache-locality invariant: +// keys hash to the same shard via cachev2.Hash, so a key's data shard and +// eviction shard align under matching shardCount. +func TestSharded_RoutesByConcurrentMapHash(t *testing.T) { + t.Parallel() + + const shardCount = 32 + + s, err := NewSharded("lru", 320, shardCount) + if err != nil { + t.Fatalf("NewSharded: %v", err) + } + + // For each test key, verify the routed shard index matches what + // cachev2.Hash would route to. + for i := range 1000 { + key := "key-" + strconv.Itoa(i) + expected := cachev2.Hash(key) & (uint32(shardCount) - 1) + + // Inspect via the hash directly — no public accessor on Sharded + // for shard index, so we recompute and trust mask consistency. + if s.mask != uint32(shardCount-1) { + t.Fatalf("Sharded.mask = %d, want %d", s.mask, shardCount-1) + } + + got := cachev2.Hash(key) & s.mask + if got != expected { + t.Errorf("key=%q: routed to shard %d, ConcurrentMap would route to %d", key, got, expected) + } + } +} + +// TestSharded_BasicSetGetDelete is the IAlgorithm contract. +func TestSharded_BasicSetGetDelete(t *testing.T) { + t.Parallel() + + s, err := NewSharded("lru", 64, 8) + if err != nil { + t.Fatalf("NewSharded: %v", err) + } + + // Set across many keys; keys should distribute across shards. + for i := range 200 { + s.Set("k"+strconv.Itoa(i), i) + } + + // Get a recently-set key should succeed. + if _, ok := s.Get("k199"); !ok { + t.Errorf("Get(k199) missing") + } + + // Delete should remove. + s.Delete("k199") + + if _, ok := s.Get("k199"); ok { + t.Errorf("Delete(k199) ineffective") + } +} + +// TestSharded_EvictDistributesAcrossShards verifies the round-robin Evict +// touches multiple shards rather than draining a single one. +func TestSharded_EvictDistributesAcrossShards(t *testing.T) { + t.Parallel() + + const shardCount = 4 + + s, err := NewSharded("lru", 32, shardCount) + if err != nil { + t.Fatalf("NewSharded: %v", err) + } + + // Insert one key per shard (use deterministic keys we can verify routing for). + keysPerShard := make(map[uint32][]string) + + for i := range 64 { + key := "k" + strconv.Itoa(i) + shardIdx := cachev2.Hash(key) & uint32(shardCount-1) + + keysPerShard[shardIdx] = append(keysPerShard[shardIdx], key) + + s.Set(key, i) + } + + // Sanity: all 4 shards received at least one key (deterministic for these keys). + if len(keysPerShard) != shardCount { + t.Fatalf("test setup: keys hashed to %d shards, want %d", len(keysPerShard), shardCount) + } + + // Evict 4 times; we should observe evictions from at least 2 distinct shards + // (round-robin should not always drain the same shard). + evictedShards := make(map[uint32]struct{}) + + for range 4 { + key, ok := s.Evict() + if !ok { + break + } + + evictedShards[cachev2.Hash(key)&uint32(shardCount-1)] = struct{}{} + } + + if len(evictedShards) < 2 { + t.Errorf("Evict drained only %d shards in 4 calls; round-robin not distributing", len(evictedShards)) + } +} + +// TestSharded_EvictReturnsFalseWhenAllEmpty covers the all-empty corner. +func TestSharded_EvictReturnsFalseWhenAllEmpty(t *testing.T) { + t.Parallel() + + s, err := NewSharded("lru", 32, 8) + if err != nil { + t.Fatalf("NewSharded: %v", err) + } + + if _, ok := s.Evict(); ok { + t.Errorf("Evict on empty Sharded returned ok=true") + } +} + +// TestSharded_ConcurrentSetAndEvict is the race-safety smoke test. +func TestSharded_ConcurrentSetAndEvict(t *testing.T) { + t.Parallel() + + s, err := NewSharded("lru", 1024, 32) + if err != nil { + t.Fatalf("NewSharded: %v", err) + } + + const writers = 8 + + const perWriter = 1000 + + var wg sync.WaitGroup + + for w := range writers { + wg.Go(func() { + for i := range perWriter { + key := "k" + strconv.Itoa(w*perWriter+i) + + s.Set(key, i) + + if i%4 == 0 { + _, _ = s.Evict() + } + } + }) + } + + wg.Wait() +} diff --git a/pool_test.go b/pool_test.go index aa21fa8..3cf6322 100644 --- a/pool_test.go +++ b/pool_test.go @@ -5,6 +5,8 @@ import ( "sync" "testing" "time" + + "github.com/hyp3rd/ewrap" ) func TestWorkerPool_EnqueueAndShutdown(t *testing.T) { @@ -37,7 +39,7 @@ func TestWorkerPool_EnqueueAndShutdown(t *testing.T) { func TestWorkerPool_JobErrorHandling(t *testing.T) { pool := NewWorkerPool(2) - expectedErr := errors.New("job error") + expectedErr := ewrap.New("job error") pool.Enqueue(func() error { return expectedErr }) diff --git a/race-baseline-v2.log b/race-baseline-v2.log new file mode 100644 index 0000000..bb5690c --- /dev/null +++ b/race-baseline-v2.log @@ -0,0 +1,21 @@ +ok github.com/hyp3rd/hypercache 4.425s +? github.com/hyp3rd/hypercache/internal/cluster [no test files] +? github.com/hyp3rd/hypercache/internal/constants [no test files] +? github.com/hyp3rd/hypercache/internal/dist [no test files] +? github.com/hyp3rd/hypercache/internal/introspect [no test files] +? github.com/hyp3rd/hypercache/internal/libs/serializer [no test files] +? github.com/hyp3rd/hypercache/internal/sentinel [no test files] +? github.com/hyp3rd/hypercache/internal/telemetry/attrs [no test files] +? github.com/hyp3rd/hypercache/internal/transport [no test files] +? github.com/hyp3rd/hypercache/pkg/backend [no test files] +? github.com/hyp3rd/hypercache/pkg/backend/redis [no test files] +? github.com/hyp3rd/hypercache/pkg/backend/rediscluster [no test files] +ok github.com/hyp3rd/hypercache/pkg/cache 2.012s +ok github.com/hyp3rd/hypercache/pkg/cache/v2 1.738s +ok github.com/hyp3rd/hypercache/pkg/eviction 2.575s +? github.com/hyp3rd/hypercache/pkg/middleware [no test files] +ok github.com/hyp3rd/hypercache/pkg/stats 6.521s +ok github.com/hyp3rd/hypercache/tests 156.721s +ok github.com/hyp3rd/hypercache/tests/benchmark 3.469s [no tests to run] +ok github.com/hyp3rd/hypercache/tests/benchmarkdist 2.787s [no tests to run] +ok github.com/hyp3rd/hypercache/tests/integration 100.665s diff --git a/race-step2.log b/race-step2.log new file mode 100644 index 0000000..67c39ce --- /dev/null +++ b/race-step2.log @@ -0,0 +1,21 @@ +ok github.com/hyp3rd/hypercache 4.069s +? github.com/hyp3rd/hypercache/internal/cluster [no test files] +? github.com/hyp3rd/hypercache/internal/constants [no test files] +? github.com/hyp3rd/hypercache/internal/dist [no test files] +? github.com/hyp3rd/hypercache/internal/introspect [no test files] +? github.com/hyp3rd/hypercache/internal/libs/serializer [no test files] +? github.com/hyp3rd/hypercache/internal/sentinel [no test files] +? github.com/hyp3rd/hypercache/internal/telemetry/attrs [no test files] +? github.com/hyp3rd/hypercache/internal/transport [no test files] +? github.com/hyp3rd/hypercache/pkg/backend [no test files] +? github.com/hyp3rd/hypercache/pkg/backend/redis [no test files] +? github.com/hyp3rd/hypercache/pkg/backend/rediscluster [no test files] +ok github.com/hyp3rd/hypercache/pkg/cache 2.752s +ok github.com/hyp3rd/hypercache/pkg/cache/v2 1.418s +ok github.com/hyp3rd/hypercache/pkg/eviction 2.675s +? github.com/hyp3rd/hypercache/pkg/middleware [no test files] +ok github.com/hyp3rd/hypercache/pkg/stats 6.676s +ok github.com/hyp3rd/hypercache/tests 133.985s +ok github.com/hyp3rd/hypercache/tests/benchmark 3.283s [no tests to run] +ok github.com/hyp3rd/hypercache/tests/benchmarkdist 3.904s [no tests to run] +ok github.com/hyp3rd/hypercache/tests/integration 100.730s diff --git a/tests/hypercache_distmemory_heartbeat_sampling_test.go b/tests/hypercache_distmemory_heartbeat_sampling_test.go index e6d4fbc..81e7a91 100644 --- a/tests/hypercache_distmemory_heartbeat_sampling_test.go +++ b/tests/hypercache_distmemory_heartbeat_sampling_test.go @@ -21,11 +21,15 @@ func TestHeartbeatSamplingAndTransitions(t *testing.T) { //nolint:paralleltest n2 := cluster.NewNode("", "n2") n3 := cluster.NewNode("", "n3") + // Intervals chosen to tolerate the 3-5x slowdown imposed by -race -count=10 + // under shuffle. Previous values (interval=15ms, dead=90ms) were tight + // enough that under heavy parallel test load the heartbeat goroutine could + // starve and never advance the dead transition within deadline. b1i, _ := backend.NewDistMemory( ctx, backend.WithDistMembership(membership, n1), backend.WithDistTransport(transport), - backend.WithDistHeartbeat(15*time.Millisecond, 40*time.Millisecond, 90*time.Millisecond), + backend.WithDistHeartbeat(80*time.Millisecond, 320*time.Millisecond, 640*time.Millisecond), backend.WithDistHeartbeatSample(0), // probe all peers per tick for deterministic transition ) From 4a9f54f8c09d4bbac52ff58f373cd79347456ae7 Mon Sep 17 00:00:00 2001 From: "F." Date: Sun, 3 May 2026 16:34:18 +0200 Subject: [PATCH 7/8] =?UTF-8?q?refactor(tests):=20modernize=20test=20suite?= =?UTF-8?q?=20=E2=80=94=20replace=20longbridgeapp/assert,=20parallelize,?= =?UTF-8?q?=20remove=20pkg/cache=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all test assertions from github.com/longbridgeapp/assert to github.com/stretchr/testify/require (promoted from indirect to direct dependency in go.mod). The longbridgeapp/assert package is fully removed. Add t.Parallel() to every applicable test function across the codebase, including hypercache_test.go, pool_test.go, pkg/eviction, pkg/stats, pkg/cache/v2, and the full tests/ and tests/integration/ trees. Tests that share a cache instance and depend on insertion order are explicitly annotated with //nolint:paralleltest. Remove pkg/cache v1 (cmap.go, item.go and their tests) entirely. The CAWOLFU eviction algorithm now uses a plain map[string]*CAWOLFUNode guarded by its own mutex instead of the redundant ConcurrentMap sharding. Additional modernization: - Fix golangci.yaml: add path-based exclusions for test-specific linters (funlen, cyclop, gocognit, dupl, gocritic, goconst, noinlineerr, revive function-length/cognitive-complexity/cyclomatic). - Rename benchmark package declarations from `tests` to `benchmark`. - Replace http.Get/client.Get with context-aware http.NewRequestWithContext. - Use t.Cleanup for deferred Stop/Close calls to surface return errors. - Wrap sentinel errors with %w; introduce errUnexpectedStatus. - Rename local `cap` constants to `windowCap` to avoid shadowing the builtin. - Use net.ListenConfig.Listen instead of net.Listen in allocatePort. - Add nolint directives for gosec, revive, and prealloc where appropriate. --- .golangci.yaml | 31 ++ cspell.config.yaml | 2 + go.mod | 3 +- go.sum | 2 - hypercache_test.go | 40 +- pkg/backend/dist_memory_test_helpers.go | 4 +- pkg/cache/cmap.go | 442 ---------------- pkg/cache/cmap_test.go | 492 ------------------ pkg/cache/item.go | 125 ----- pkg/cache/item_test.go | 174 ------- pkg/cache/v2/cmap_test.go | 51 +- pkg/eviction/arc_test.go | 20 +- pkg/eviction/cawolfu.go | 70 ++- pkg/eviction/cawolfu_test.go | 14 +- pkg/eviction/clock_test.go | 6 + pkg/eviction/lfu_test.go | 8 + pkg/eviction/lru_test.go | 14 +- pkg/stats/histogramcollector_test.go | 44 +- pool_test.go | 14 + .../hypercache_concurrency_benchmark_test.go | 2 +- .../hypercache_get_benchmark_test.go | 2 +- .../hypercache_list_benchmark_test.go | 2 +- .../hypercache_set_benchmark_test.go | 2 +- ...ache_distmemory_heartbeat_sampling_test.go | 6 +- ...percache_distmemory_hinted_handoff_test.go | 13 +- .../hypercache_distmemory_integration_test.go | 2 + ...cache_distmemory_remove_readrepair_test.go | 6 + ...hypercache_distmemory_stale_quorum_test.go | 2 + ...hypercache_distmemory_write_quorum_test.go | 6 +- tests/hypercache_get_multiple_test.go | 29 +- tests/hypercache_get_or_set_test.go | 22 +- tests/hypercache_get_test.go | 16 +- tests/hypercache_http_merkle_test.go | 15 +- tests/hypercache_mgmt_dist_test.go | 3 +- tests/hypercache_set_test.go | 16 +- tests/hypercache_trigger_eviction_test.go | 14 +- tests/integration/dist_phase1_test.go | 15 +- .../integration/dist_rebalance_leave_test.go | 2 + .../dist_rebalance_replica_diff_test.go | 4 +- ...st_rebalance_replica_diff_throttle_test.go | 4 +- tests/integration/dist_rebalance_test.go | 24 +- tests/management_http_test.go | 49 +- tests/merkle_delete_tombstone_test.go | 2 + tests/merkle_empty_tree_test.go | 2 + tests/merkle_no_diff_test.go | 2 + tests/merkle_single_missing_key_test.go | 2 + tests/merkle_sync_test.go | 6 +- 47 files changed, 405 insertions(+), 1421 deletions(-) delete mode 100644 pkg/cache/cmap.go delete mode 100644 pkg/cache/cmap_test.go delete mode 100644 pkg/cache/item.go delete mode 100644 pkg/cache/item_test.go diff --git a/.golangci.yaml b/.golangci.yaml index 4ebe1bd..fea5c95 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -63,6 +63,37 @@ linters: # (access to unexported helpers without re-exporting). Documented choice. - testpackage + exclusions: + # Path-based exclusions for rules that produce false-positive noise on test + # code without corresponding signal. Production code remains under the full + # rule set; only `_test.go` files are exempted from these specific rules. + # + # Justification per rule: + # - funlen / cyclop / gocognit: tests legitimately have long bodies + # (setup -> many actions -> many assertions); splitting into helpers + # adds noise without surfacing real defects. + # - dupl: table-driven test bodies repeat by design. + # - gocritic: subjective stylistic suggestions, low signal on tests. + # - goconst: short fixture strings ("v1", "key1") naturally repeat in + # tests; constants would obscure intent. + # - noinlineerr: stylistic preference, not a defect. + # - revive function-length / cognitive-complexity / cyclomatic: same + # reasons as funlen / cyclop / gocognit (revive's variants). + rules: + - path: _test\.go$ + linters: + - funlen + - cyclop + - gocognit + - dupl + - gocritic + - goconst + - noinlineerr + - path: _test\.go$ + linters: + - revive + text: '^(function-length|cognitive-complexity|cyclomatic):' + settings: cyclop: # The maximal code complexity to report. diff --git a/cspell.config.yaml b/cspell.config.yaml index 8c15280..e07fca2 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -148,8 +148,10 @@ words: - SLRU - staticcheck - stdlib + - stretchr - strfnv - strs + - subtests - sval - thelper - toplevel diff --git a/go.mod b/go.mod index 3fe6502..54f77f6 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/gofiber/fiber/v3 v3.2.0 github.com/hyp3rd/ewrap v1.5.0 github.com/hyp3rd/sectools v1.2.5 - github.com/longbridgeapp/assert v1.1.0 github.com/redis/go-redis/v9 v9.19.0 + github.com/stretchr/testify v1.11.1 github.com/ugorji/go/codec v1.3.1 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/metric v1.43.0 @@ -28,7 +28,6 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.70.0 // indirect diff --git a/go.sum b/go.sum index 36cdfd9..2f26566 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/longbridgeapp/assert v1.1.0 h1:L+/HISOhuGbNAAmJNXgk3+Tm5QmSB70kwdktJXgjL+I= -github.com/longbridgeapp/assert v1.1.0/go.mod h1:UOI7O3rzlzlz715lQm0atWs6JbrYGuIJUEeOekutL6o= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= diff --git a/hypercache_test.go b/hypercache_test.go index a2a8b85..47ba551 100644 --- a/hypercache_test.go +++ b/hypercache_test.go @@ -5,13 +5,15 @@ import ( "testing" "time" - "github.com/longbridgeapp/assert" + "github.com/stretchr/testify/require" "github.com/hyp3rd/hypercache/internal/constants" "github.com/hyp3rd/hypercache/pkg/backend" ) func TestHyperCache_New(t *testing.T) { + t.Parallel() + // Test that an error is returned when the capacity is negative _, err := NewInMemoryWithDefaults(context.TODO(), -1) if err == nil { @@ -40,17 +42,21 @@ func TestHyperCache_New(t *testing.T) { } func TestHyperCache_WithStatsCollector(t *testing.T) { + t.Parallel() + // Test with default stats collector cache, err := NewInMemoryWithDefaults(context.TODO(), 10) - assert.Nil(t, err) - assert.NotNil(t, cache.StatsCollector) + require.NoError(t, err) + require.NotNil(t, cache.StatsCollector) } func TestHyperCache_WithExpirationInterval(t *testing.T) { + t.Parallel() + // Test with default expiration interval cache, err := NewInMemoryWithDefaults(context.TODO(), 10) - assert.Nil(t, err) - assert.Equal(t, 30*time.Minute, cache.expirationInterval) + require.NoError(t, err) + require.Equal(t, 30*time.Minute, cache.expirationInterval) config := &Config[backend.InMemory]{ BackendType: constants.InMemoryBackend, @@ -65,15 +71,17 @@ func TestHyperCache_WithExpirationInterval(t *testing.T) { hcm := GetDefaultManager() cache, err = New(context.TODO(), hcm, config) - assert.Nil(t, err) - assert.Equal(t, 1*time.Hour, cache.expirationInterval) + require.NoError(t, err) + require.Equal(t, 1*time.Hour, cache.expirationInterval) } func TestHyperCache_WithEvictionInterval(t *testing.T) { + t.Parallel() + // Test with default eviction interval cache, err := NewInMemoryWithDefaults(context.TODO(), 10) - assert.Nil(t, err) - assert.Equal(t, 10*time.Minute, cache.evictionInterval) + require.NoError(t, err) + require.Equal(t, 10*time.Minute, cache.evictionInterval) // Test with custom eviction interval config := &Config[backend.InMemory]{ @@ -88,15 +96,17 @@ func TestHyperCache_WithEvictionInterval(t *testing.T) { hcm := GetDefaultManager() // Test with custom eviction interval cache, err = New(context.TODO(), hcm, config) - assert.Nil(t, err) - assert.Equal(t, 1*time.Hour, cache.evictionInterval) + require.NoError(t, err) + require.Equal(t, 1*time.Hour, cache.evictionInterval) } func TestHyperCache_WithMaxEvictionCount(t *testing.T) { + t.Parallel() + // Test with default max eviction count cache, err := NewInMemoryWithDefaults(context.TODO(), 10) - assert.Nil(t, err) - assert.Equal(t, uint(10), cache.maxEvictionCount) + require.NoError(t, err) + require.Equal(t, uint(10), cache.maxEvictionCount) // Test with custom max eviction count config := &Config[backend.InMemory]{ @@ -112,6 +122,6 @@ func TestHyperCache_WithMaxEvictionCount(t *testing.T) { hcm := GetDefaultManager() cache, err = New(context.TODO(), hcm, config) - assert.Nil(t, err) - assert.Equal(t, uint(5), cache.maxEvictionCount) + require.NoError(t, err) + require.Equal(t, uint(5), cache.maxEvictionCount) } diff --git a/pkg/backend/dist_memory_test_helpers.go b/pkg/backend/dist_memory_test_helpers.go index 8f6f268..e5f1460 100644 --- a/pkg/backend/dist_memory_test_helpers.go +++ b/pkg/backend/dist_memory_test_helpers.go @@ -18,7 +18,7 @@ func (dm *DistMemory) DisableHTTPForTest(ctx context.Context) { //nolint:ireturn dm.httpServer = nil } - dm.transport = nil + dm.storeTransport(nil) } // EnableHTTPForTest restarts HTTP server & transport if nodeAddr is set (testing helper). @@ -52,7 +52,7 @@ func (dm *DistMemory) EnableHTTPForTest(ctx context.Context) { //nolint:ireturn return "", false } - dm.transport = NewDistHTTPTransport(2*time.Second, resolver) + dm.storeTransport(NewDistHTTPTransport(2*time.Second, resolver)) } // HintedQueueSize returns number of queued hints for a node (testing helper). diff --git a/pkg/cache/cmap.go b/pkg/cache/cmap.go deleted file mode 100644 index 7eac1f0..0000000 --- a/pkg/cache/cmap.go +++ /dev/null @@ -1,442 +0,0 @@ -// Package cache provides a thread-safe concurrent map implementation with sharding -// for improved performance in high-concurrency scenarios. -package cache - -import ( - "fmt" - "sync" - - "github.com/goccy/go-json" - "github.com/hyp3rd/ewrap" -) - -// ShardCount is the number of shards. -const ( - ShardCount = 32 - prime32 uint32 = 16777619 - offset32 uint32 = 2166136261 -) - -// Stringer is the interface implemented by any value that has a String method,. -type Stringer interface { - fmt.Stringer - comparable -} - -// ConcurrentMap is a "thread" safe map of type string:Anything. -// To avoid lock bottlenecks this map is dived to several (ShardCount) map shards. -type ConcurrentMap[K comparable, V any] struct { - shards []*ConcurrentMapShared[K, V] - sharding func(key K) uint32 -} - -// ConcurrentMapShared is a "thread" safe string to anything map. -type ConcurrentMapShared[K comparable, V any] struct { - sync.RWMutex // Read Write mutex, guards access to internal map. - - items map[K]V -} - -func create[K comparable, V any](sharding func(key K) uint32) ConcurrentMap[K, V] { - cmap := ConcurrentMap[K, V]{ - sharding: sharding, - shards: make([]*ConcurrentMapShared[K, V], ShardCount), - } - for i := range ShardCount { - cmap.shards[i] = &ConcurrentMapShared[K, V]{items: make(map[K]V)} - } - - return cmap -} - -// New creates a new concurrent map. -func New[V any]() ConcurrentMap[string, V] { - return create[string, V](fnv32) -} - -// NewStringer creates a new concurrent map. -func NewStringer[K Stringer, V any]() ConcurrentMap[K, V] { - return create[K, V](strfnv32[K]) -} - -// NewWithCustomShardingFunction creates a new concurrent map. -func NewWithCustomShardingFunction[K comparable, V any](sharding func(key K) uint32) ConcurrentMap[K, V] { - return create[K, V](sharding) -} - -// GetShard returns shard under given key. -func (m ConcurrentMap[K, V]) GetShard(key K) *ConcurrentMapShared[K, V] { - return m.shards[uint(m.sharding(key))%uint(ShardCount)] -} - -// MSet Sets the given value under the specified key. -func (m ConcurrentMap[K, V]) MSet(data map[K]V) { - for key, value := range data { - shard := m.GetShard(key) - shard.Lock() - - shard.items[key] = value - shard.Unlock() - } -} - -// Set Sets the given value under the specified key. -func (m ConcurrentMap[K, V]) Set(key K, value V) { - // Get map shard. - shard := m.GetShard(key) - shard.Lock() - - shard.items[key] = value - shard.Unlock() -} - -// UpsertCb callback to return new element to be inserted into the map -// It is called while lock is held, therefore it MUST NOT -// try to access other keys in same map, as it can lead to deadlock since -// Go sync.RWLock is not reentrant. -type UpsertCb[V any] func(exist bool, valueInMap, newValue V) V - -// Upsert Insert or Update - updates existing element or inserts a new one using UpsertCb. -func (m ConcurrentMap[K, V]) Upsert(key K, value V, cb UpsertCb[V]) V { - shard := m.GetShard(key) - shard.Lock() - - v, ok := shard.items[key] - res := cb(ok, v, value) - - shard.items[key] = res - shard.Unlock() - - return res -} - -// SetIfAbsent sets the given value under the specified key if no value was associated with it. -func (m ConcurrentMap[K, V]) SetIfAbsent(key K, value V) bool { - // Get map shard. - shard := m.GetShard(key) - shard.Lock() - - _, ok := shard.items[key] - if !ok { - shard.items[key] = value - } - - shard.Unlock() - - return !ok -} - -// Get retrieves an element from map under given key. -func (m ConcurrentMap[K, V]) Get(key K) (V, bool) { - // Get shard - shard := m.GetShard(key) - shard.RLock() - - // Get item from shard. - val, ok := shard.items[key] - shard.RUnlock() - - return val, ok -} - -// Count returns the number of elements within the map. -func (m ConcurrentMap[K, V]) Count() int { - count := 0 - - for i := range ShardCount { - shard := m.shards[i] - shard.RLock() - - count += len(shard.items) - shard.RUnlock() - } - - return count -} - -// Has looks up an item under specified key. -func (m ConcurrentMap[K, V]) Has(key K) bool { - // Get shard - shard := m.GetShard(key) - shard.RLock() - - // See if element is within shard. - _, ok := shard.items[key] - shard.RUnlock() - - return ok -} - -// Remove removes an element from the map. -func (m ConcurrentMap[K, V]) Remove(key K) error { - // Try to get shard. - shard := m.GetShard(key) - if shard == nil { - return ewrap.New("key not found") - } - - shard.Lock() - delete(shard.items, key) - shard.Unlock() - - return nil -} - -// RemoveCb is a callback executed in a map.RemoveCb() call, while Lock is held -// If returns true, the element will be removed from the map. -type RemoveCb[K any, V any] func(key K, v V, exists bool) bool - -// RemoveCb locks the shard containing the key, retrieves its current value and calls the callback with those params -// If callback returns true and element exists, it will remove it from the map -// Returns the value returned by the callback (even if element was not present in the map). -func (m ConcurrentMap[K, V]) RemoveCb(key K, cb RemoveCb[K, V]) bool { - // Try to get shard. - shard := m.GetShard(key) - shard.Lock() - - v, ok := shard.items[key] - - remove := cb(key, v, ok) - if remove && ok { - delete(shard.items, key) - } - - shard.Unlock() - - return remove -} - -// Pop removes an element from the map and returns it. -func (m ConcurrentMap[K, V]) Pop(key K) (V, bool) { - // Try to get shard. - shard := m.GetShard(key) - shard.Lock() - - v, exists := shard.items[key] - delete(shard.items, key) - shard.Unlock() - - return v, exists -} - -// IsEmpty checks if map is empty. -func (m ConcurrentMap[K, V]) IsEmpty() bool { - return m.Count() == 0 -} - -// Tuple is used by the Iter & IterBuffered functions to wrap two variables together over a channel,. -type Tuple[K comparable, V any] struct { - Key K - Val V -} - -// IterBuffered returns a buffered iterator which could be used in a for range loop. -func (m ConcurrentMap[K, V]) IterBuffered() <-chan Tuple[K, V] { - chans := snapshot(m) - - total := 0 - for _, c := range chans { - total += cap(c) - } - - ch := make(chan Tuple[K, V], total) - go fanIn(chans, ch) - - return ch -} - -// Clear removes all items from map. -func (m ConcurrentMap[K, V]) Clear() error { - eg := ewrap.NewErrorGroup() - - for item := range m.IterBuffered() { - err := m.Remove(item.Key) - if err != nil { - eg.Add(err) - } - } - - return eg.ErrorOrNil() -} - -// Returns a array of channels that contains elements in each shard, -// which likely takes a snapshot of `m`. -// It returns once the size of each buffered channel is determined, -// before all the channels are populated using goroutines. -func snapshot[Key comparable, Val any](cmap ConcurrentMap[Key, Val]) []chan Tuple[Key, Val] { - // When you access map items before initializing. - if len(cmap.shards) == 0 { - panic(`cmap.ConcurrentMap is not initialized. Should run New() before usage.`) - } - - chans := make([]chan Tuple[Key, Val], ShardCount) - wg := sync.WaitGroup{} - wg.Add(ShardCount) - - // Foreach shard. - for index, shard := range cmap.shards { - go func(index int, shard *ConcurrentMapShared[Key, Val]) { - // Foreach key, value pair. - shard.RLock() - - chans[index] = make(chan Tuple[Key, Val], len(shard.items)) - - wg.Done() - - for key, val := range shard.items { - chans[index] <- Tuple[Key, Val]{key, val} - } - - shard.RUnlock() - close(chans[index]) - }(index, shard) - } - - wg.Wait() - - return chans -} - -// fanIn reads elements from channels `chans` into channel `out`. -func fanIn[K comparable, V any](chans []chan Tuple[K, V], out chan Tuple[K, V]) { - wg := sync.WaitGroup{} - wg.Add(len(chans)) - - for _, ch := range chans { - go func(ch chan Tuple[K, V]) { - for t := range ch { - out <- t - } - - wg.Done() - }(ch) - } - - wg.Wait() - close(out) -} - -// Items returns all items as map[string]V. -func (m ConcurrentMap[K, V]) Items() map[K]V { - tmp := make(map[K]V) - - // Insert items to temporary map. - for item := range m.IterBuffered() { - tmp[item.Key] = item.Val - } - - return tmp -} - -// IterCb is the iterator calledback for every key,value found in -// maps. RLock is held for all calls for a given shard -// therefore callback sess consistent view of a shard, -// but not across the shards. -type IterCb[K comparable, V any] func(key K, v V) - -// IterCb is a callback based iterator, cheapest way to read -// all elements in a map. -func (m ConcurrentMap[K, V]) IterCb(fn IterCb[K, V]) { - for idx := range m.shards { - shard := (m.shards)[idx] - shard.RLock() - - for key, value := range shard.items { - fn(key, value) - } - - shard.RUnlock() - } -} - -// Keys returns all keys as []string. -func (m ConcurrentMap[K, V]) Keys() []K { - count := m.Count() - ch := make(chan K, count) - - go func() { - // Foreach shard. - wg := sync.WaitGroup{} - wg.Add(ShardCount) - - for _, shard := range m.shards { - go func(shard *ConcurrentMapShared[K, V]) { - // Foreach key, value pair. - shard.RLock() - - for key := range shard.items { - ch <- key - } - - shard.RUnlock() - wg.Done() - }(shard) - } - - wg.Wait() - close(ch) - }() - - // Generate keys - keys := make([]K, 0, count) - for k := range ch { - keys = append(keys, k) - } - - return keys -} - -// MarshalJSON reviles ConcurrentMap "private" variables to json marshal. -func (m ConcurrentMap[K, V]) MarshalJSON() ([]byte, error) { - // Create a temporary map, which will hold all item spread across shards. - tmp := make(map[K]V) - - // Insert items to temporary map. - for item := range m.IterBuffered() { - tmp[item.Key] = item.Val - } - - data, err := json.Marshal(tmp) - if err != nil { - return nil, ewrap.Wrap(err, "failed to marshal json") - } - - return data, nil -} - -// Returns a hash for a key. -func strfnv32[K fmt.Stringer](key K) uint32 { - return fnv32(key.String()) -} - -// Returns a hash for a string. -func fnv32(key string) uint32 { - hash := offset32 - - keyLength := len(key) - for i := range keyLength { - hash *= prime32 - - hash ^= uint32(key[i]) - } - - return hash -} - -// UnmarshalJSON reverse process of Marshal. -func (m *ConcurrentMap[K, V]) UnmarshalJSON(b []byte) error { - tmp := make(map[K]V) - - // Unmarshal into a single map. - err := json.Unmarshal(b, &tmp) - if err != nil { - return ewrap.Wrap(err, "failed to unmarshal json") - } - - // foreach key,value pair in temporary map insert into our concurrent map. - for key, val := range tmp { - m.Set(key, val) - } - - return nil -} diff --git a/pkg/cache/cmap_test.go b/pkg/cache/cmap_test.go deleted file mode 100644 index f80e95e..0000000 --- a/pkg/cache/cmap_test.go +++ /dev/null @@ -1,492 +0,0 @@ -package cache - -import ( - "fmt" - "sync" - "testing" - - "github.com/goccy/go-json" -) - -type testStringer struct { - value string -} - -func (t testStringer) String() string { - return t.value -} - -func TestNew(t *testing.T) { - cmap := New[int]() - if cmap.Count() != 0 { - t.Errorf("Expected count 0, got %d", cmap.Count()) - } - - if !cmap.IsEmpty() { - t.Error("Expected map to be empty") - } -} - -func TestNewStringer(t *testing.T) { - cmap := NewStringer[testStringer, int]() - if cmap.Count() != 0 { - t.Errorf("Expected count 0, got %d", cmap.Count()) - } -} - -func TestNewWithCustomShardingFunction(t *testing.T) { - customSharding := func(key string) uint32 { - return 0 // Always return 0 for testing - } - - cmap := NewWithCustomShardingFunction[string, int](customSharding) - if cmap.Count() != 0 { - t.Errorf("Expected count 0, got %d", cmap.Count()) - } -} - -func TestSetAndGet(t *testing.T) { - cmap := New[int]() - key := "test" - value := 42 - - cmap.Set(key, value) - - got, exists := cmap.Get(key) - if !exists { - t.Error("Expected key to exist") - } - - if got != value { - t.Errorf("Expected %d, got %d", value, got) - } -} - -func TestMSet(t *testing.T) { - cmap := New[int]() - data := map[string]int{ - "key1": 1, - "key2": 2, - "key3": 3, - } - - cmap.MSet(data) - - for key, expected := range data { - if got, exists := cmap.Get(key); !exists || got != expected { - t.Errorf("Expected %d for key %s, got %d (exists: %v)", expected, key, got, exists) - } - } -} - -func TestUpsert(t *testing.T) { - cmap := New[int]() - key := "test" - - // Insert new value - result := cmap.Upsert(key, 10, func(exist bool, valueInMap, newValue int) int { - if !exist { - return newValue - } - - return valueInMap + newValue - }) - - if result != 10 { - t.Errorf("Expected 10, got %d", result) - } - - // Update existing value - result = cmap.Upsert(key, 5, func(exist bool, valueInMap, newValue int) int { - if !exist { - return newValue - } - - return valueInMap + newValue - }) - - if result != 15 { - t.Errorf("Expected 15, got %d", result) - } -} - -func TestSetIfAbsent(t *testing.T) { - cmap := New[int]() - key := "test" - value := 42 - - // Should set successfully - if !cmap.SetIfAbsent(key, value) { - t.Error("Expected SetIfAbsent to return true for new key") - } - - // Should not set again - if cmap.SetIfAbsent(key, 100) { - t.Error("Expected SetIfAbsent to return false for existing key") - } - - if got, _ := cmap.Get(key); got != value { - t.Errorf("Expected %d, got %d", value, got) - } -} - -func TestHas(t *testing.T) { - cmap := New[int]() - key := "test" - - if cmap.Has(key) { - t.Error("Expected key to not exist") - } - - cmap.Set(key, 42) - - if !cmap.Has(key) { - t.Error("Expected key to exist") - } -} - -func TestRemove(t *testing.T) { - cmap := New[int]() - key := "test" - cmap.Set(key, 42) - - err := cmap.Remove(key) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if cmap.Has(key) { - t.Error("Expected key to be removed") - } -} - -func TestRemoveCb(t *testing.T) { - cmap := New[int]() - key := "test" - value := 42 - cmap.Set(key, value) - - removed := cmap.RemoveCb(key, func(k string, v int, exists bool) bool { - if !exists { - t.Error("Expected key to exist in callback") - } - - if v != value { - t.Errorf("Expected value %d in callback, got %d", value, v) - } - - return true - }) - - if !removed { - t.Error("Expected RemoveCb to return true") - } - - if cmap.Has(key) { - t.Error("Expected key to be removed") - } -} - -func TestPop(t *testing.T) { - cmap := New[int]() - key := "test" - value := 42 - cmap.Set(key, value) - - got, exists := cmap.Pop(key) - if !exists { - t.Error("Expected key to exist") - } - - if got != value { - t.Errorf("Expected %d, got %d", value, got) - } - - if cmap.Has(key) { - t.Error("Expected key to be removed after pop") - } -} - -func TestCount(t *testing.T) { - cmap := New[int]() - - if cmap.Count() != 0 { - t.Errorf("Expected count 0, got %d", cmap.Count()) - } - - cmap.Set("key1", 1) - cmap.Set("key2", 2) - - if cmap.Count() != 2 { - t.Errorf("Expected count 2, got %d", cmap.Count()) - } -} - -func TestIsEmpty(t *testing.T) { - cmap := New[int]() - - if !cmap.IsEmpty() { - t.Error("Expected map to be empty") - } - - cmap.Set("key", 1) - - if cmap.IsEmpty() { - t.Error("Expected map to not be empty") - } -} - -func TestKeys(t *testing.T) { - cmap := New[int]() - expectedKeys := []string{"key1", "key2", "key3"} - - for _, key := range expectedKeys { - cmap.Set(key, 1) - } - - keys := cmap.Keys() - if len(keys) != len(expectedKeys) { - t.Errorf("Expected %d keys, got %d", len(expectedKeys), len(keys)) - } - - keyMap := make(map[string]bool) - for _, key := range keys { - keyMap[key] = true - } - - for _, expected := range expectedKeys { - if !keyMap[expected] { - t.Errorf("Expected key %s not found", expected) - } - } -} - -func TestItems(t *testing.T) { - cmap := New[int]() - expected := map[string]int{ - "key1": 1, - "key2": 2, - "key3": 3, - } - - for key, value := range expected { - cmap.Set(key, value) - } - - items := cmap.Items() - if len(items) != len(expected) { - t.Errorf("Expected %d items, got %d", len(expected), len(items)) - } - - for key, expectedValue := range expected { - if got, exists := items[key]; !exists || got != expectedValue { - t.Errorf("Expected %d for key %s, got %d (exists: %v)", expectedValue, key, got, exists) - } - } -} - -func TestIterCb(t *testing.T) { - cmap := New[int]() - expected := map[string]int{ - "key1": 1, - "key2": 2, - "key3": 3, - } - - for key, value := range expected { - cmap.Set(key, value) - } - - visited := make(map[string]int) - cmap.IterCb(func(key string, value int) { - visited[key] = value - }) - - if len(visited) != len(expected) { - t.Errorf("Expected %d items visited, got %d", len(expected), len(visited)) - } - - for key, expectedValue := range expected { - if got, exists := visited[key]; !exists || got != expectedValue { - t.Errorf("Expected %d for key %s, got %d (exists: %v)", expectedValue, key, got, exists) - } - } -} - -func TestIterBuffered(t *testing.T) { - cmap := New[int]() - expected := map[string]int{ - "key1": 1, - "key2": 2, - "key3": 3, - } - - for key, value := range expected { - cmap.Set(key, value) - } - - visited := make(map[string]int) - for item := range cmap.IterBuffered() { - visited[item.Key] = item.Val - } - - if len(visited) != len(expected) { - t.Errorf("Expected %d items visited, got %d", len(expected), len(visited)) - } - - for key, expectedValue := range expected { - if got, exists := visited[key]; !exists || got != expectedValue { - t.Errorf("Expected %d for key %s, got %d (exists: %v)", expectedValue, key, got, exists) - } - } -} - -func TestClear(t *testing.T) { - cmap := New[int]() - cmap.Set("key1", 1) - cmap.Set("key2", 2) - - err := cmap.Clear() - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - if !cmap.IsEmpty() { - t.Error("Expected map to be empty after clear") - } -} - -func TestMarshalJSON(t *testing.T) { - cmap := New[int]() - expected := map[string]int{ - "key1": 1, - "key2": 2, - } - - for key, value := range expected { - cmap.Set(key, value) - } - - data, err := cmap.MarshalJSON() - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - var result map[string]int - - err = json.Unmarshal(data, &result) - if err != nil { - t.Errorf("Failed to unmarshal: %v", err) - } - - for key, expectedValue := range expected { - if got, exists := result[key]; !exists || got != expectedValue { - t.Errorf("Expected %d for key %s, got %d (exists: %v)", expectedValue, key, got, exists) - } - } -} - -func TestUnmarshalJSON(t *testing.T) { - expected := map[string]int{ - "key1": 1, - "key2": 2, - } - - data, err := json.Marshal(expected) - if err != nil { - t.Errorf("Failed to marshal: %v", err) - } - - cmap := New[int]() - - err = cmap.UnmarshalJSON(data) - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - - for key, expectedValue := range expected { - if got, exists := cmap.Get(key); !exists || got != expectedValue { - t.Errorf("Expected %d for key %s, got %d (exists: %v)", expectedValue, key, got, exists) - } - } -} - -func TestGetShard(t *testing.T) { - cmap := New[int]() - - shard := cmap.GetShard("test") - if shard == nil { - t.Error("Expected non-nil shard") - } -} - -func TestConcurrency(t *testing.T) { - cmap := New[int]() - - var wg sync.WaitGroup - - numGoroutines := 100 - numOperations := 1000 - - // Concurrent writes - wg.Add(numGoroutines) - - for i := range numGoroutines { - go func(id int) { - defer wg.Done() - - for j := range numOperations { - key := fmt.Sprintf("key-%d-%d", id, j) - cmap.Set(key, id*numOperations+j) - } - }(i) - } - - // Concurrent reads - wg.Add(numGoroutines) - - for i := range numGoroutines { - go func(id int) { - defer wg.Done() - - for j := range numOperations { - key := fmt.Sprintf("key-%d-%d", id, j) - cmap.Get(key) - } - }(i) - } - - wg.Wait() -} - -func TestFnv32(t *testing.T) { - hash1 := fnv32("test") - hash2 := fnv32("test") - hash3 := fnv32("different") - - if hash1 != hash2 { - t.Error("Same strings should produce same hash") - } - - if hash1 == hash3 { - t.Error("Different strings should produce different hashes") - } -} - -func TestStrfnv32(t *testing.T) { - str1 := testStringer{"test"} - str2 := testStringer{"test"} - str3 := testStringer{"different"} - - hash1 := strfnv32(str1) - hash2 := strfnv32(str2) - hash3 := strfnv32(str3) - - if hash1 != hash2 { - t.Error("Same strings should produce same hash") - } - - if hash1 == hash3 { - t.Error("Different strings should produce different hashes") - } -} diff --git a/pkg/cache/item.go b/pkg/cache/item.go deleted file mode 100644 index 9b10a7f..0000000 --- a/pkg/cache/item.go +++ /dev/null @@ -1,125 +0,0 @@ -package cache - -import ( - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/ugorji/go/codec" - - "github.com/hyp3rd/hypercache/internal/sentinel" -) - -const ( - bytesPerKB = 1024 -) - -// ItemPoolManager manages Item object pools for memory efficiency. -type ItemPoolManager struct { - pool sync.Pool -} - -// NewItemPoolManager creates a new ItemPoolManager with default configuration. -func NewItemPoolManager() *ItemPoolManager { - return &ItemPoolManager{ - pool: sync.Pool{ - New: func() any { - return &Item{} - }, - }, - } -} - -// Get retrieves an Item from the pool. -func (m *ItemPoolManager) Get() *Item { - item, ok := m.pool.Get().(*Item) - if !ok { - return &Item{} - } - - return item -} - -// Put returns an Item to the pool. -func (m *ItemPoolManager) Put(item *Item) { - if item == nil { - return - } - - // Zero the struct to avoid retaining large references across pool reuses - *item = Item{} - m.pool.Put(item) -} - -// Item is a struct that represents an item in the cache. -// It has a key, value, expiration duration, and a last access time field. -type Item struct { - Key string // key of the item - Value any // Value of the item - Size int64 // Size of the item, in bytes - Expiration time.Duration // Expiration duration of the item - LastAccess time.Time // LastAccess time of the item - AccessCount uint // AccessCount of times the item has been accessed -} - -// SetSize stores the size of the Item in bytes. -func (item *Item) SetSize() error { - // Use local buffer for thread safety - var buf []byte - - enc := codec.NewEncoderBytes(&buf, &codec.CborHandle{}) - - err := enc.Encode(item.Value) - if err != nil { - return sentinel.ErrInvalidSize - } - - item.Size = int64(len(buf)) - - return nil -} - -// SizeMB returns the size of the Item in megabytes. -func (item *Item) SizeMB() float64 { - return float64(item.Size) / (bytesPerKB * bytesPerKB) -} - -// SizeKB returns the size of the Item in kilobytes. -func (item *Item) SizeKB() float64 { - return float64(item.Size) / bytesPerKB -} - -// Touch updates the last access time of the item and increments the access count. -func (item *Item) Touch() { - item.LastAccess = time.Now() - item.AccessCount++ -} - -// Valid returns an error if the item is invalid, nil otherwise. -func (item *Item) Valid() error { - // Check for empty key - if strings.TrimSpace(item.Key) == "" { - return sentinel.ErrInvalidKey - } - - // Check for nil value - if item.Value == nil { - return sentinel.ErrNilValue - } - - // Check for negative expiration - if atomic.LoadInt64((*int64)(&item.Expiration)) < 0 { - atomic.StoreInt64((*int64)(&item.Expiration), 0) - - return sentinel.ErrInvalidExpiration - } - - return nil -} - -// Expired returns true if the item has expired, false otherwise. -func (item *Item) Expired() bool { - // If the expiration duration is 0, the item never expires - return item.Expiration > 0 && time.Since(item.LastAccess) > item.Expiration -} diff --git a/pkg/cache/item_test.go b/pkg/cache/item_test.go deleted file mode 100644 index a0a937f..0000000 --- a/pkg/cache/item_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package cache - -import ( - "errors" - "testing" - "time" - - "github.com/hyp3rd/hypercache/internal/sentinel" -) - -func TestNewItemPoolManager(t *testing.T) { - manager := NewItemPoolManager() - if manager == nil { - t.Fatal("Expected non-nil ItemPoolManager") - } -} - -func TestItemPoolManager_Get(t *testing.T) { - manager := NewItemPoolManager() - - item := manager.Get() - if item == nil { - t.Fatal("Expected non-nil Item") - } -} - -func TestItemPoolManager_Put(t *testing.T) { - manager := NewItemPoolManager() - - // Test putting nil item - manager.Put(nil) - - // Test putting valid item - item := &Item{Key: "test", Value: "value"} - manager.Put(item) - - // Get item back and verify it's zeroed - retrieved := manager.Get() - if retrieved.Key != "" || retrieved.Value != nil { - t.Error("Expected item to be zeroed after Put") - } -} - -func TestItem_SetSize(t *testing.T) { - item := &Item{Value: "test string"} - - err := item.SetSize() - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - if item.Size <= 0 { - t.Error("Expected positive size") - } -} - -func TestItem_SizeMB(t *testing.T) { - item := &Item{Size: 1024 * 1024} // 1 MB - - sizeMB := item.SizeMB() - if sizeMB != 1.0 { - t.Errorf("Expected 1.0 MB, got %f", sizeMB) - } -} - -func TestItem_SizeKB(t *testing.T) { - item := &Item{Size: 1024} // 1 KB - - sizeKB := item.SizeKB() - if sizeKB != 1.0 { - t.Errorf("Expected 1.0 KB, got %f", sizeKB) - } -} - -func TestItem_Touch(t *testing.T) { - item := &Item{} - initialTime := item.LastAccess - initialCount := item.AccessCount - - time.Sleep(time.Millisecond) // Ensure time difference - item.Touch() - - if !item.LastAccess.After(initialTime) { - t.Error("Expected LastAccess to be updated") - } - - if item.AccessCount != initialCount+1 { - t.Errorf("Expected AccessCount to be %d, got %d", initialCount+1, item.AccessCount) - } -} - -func TestItem_Valid(t *testing.T) { - tests := []struct { - name string - item *Item - wantErr error - }{ - { - name: "empty key", - item: &Item{Key: "", Value: "test"}, - wantErr: sentinel.ErrInvalidKey, - }, - { - name: "whitespace key", - item: &Item{Key: " ", Value: "test"}, - wantErr: sentinel.ErrInvalidKey, - }, - { - name: "nil value", - item: &Item{Key: "test", Value: nil}, - wantErr: sentinel.ErrNilValue, - }, - { - name: "negative expiration", - item: &Item{Key: "test", Value: "value", Expiration: -time.Second}, - wantErr: sentinel.ErrInvalidExpiration, - }, - { - name: "valid item", - item: &Item{Key: "test", Value: "value", Expiration: time.Second}, - wantErr: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.item.Valid() - if !errors.Is(err, tt.wantErr) { - t.Errorf("Expected error %v, got %v", tt.wantErr, err) - } - }) - } -} - -func TestItem_Expired(t *testing.T) { - tests := []struct { - name string - item *Item - wantExpired bool - }{ - { - name: "never expires (zero expiration)", - item: &Item{ - Expiration: 0, - LastAccess: time.Now().Add(-time.Hour), - }, - wantExpired: false, - }, - { - name: "not expired", - item: &Item{ - Expiration: time.Hour, - LastAccess: time.Now().Add(-time.Minute), - }, - wantExpired: false, - }, - { - name: "expired", - item: &Item{ - Expiration: time.Minute, - LastAccess: time.Now().Add(-time.Hour), - }, - wantExpired: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.item.Expired(); got != tt.wantExpired { - t.Errorf("Expected expired=%v, got %v", tt.wantExpired, got) - } - }) - } -} diff --git a/pkg/cache/v2/cmap_test.go b/pkg/cache/v2/cmap_test.go index 91311e9..97ec11b 100644 --- a/pkg/cache/v2/cmap_test.go +++ b/pkg/cache/v2/cmap_test.go @@ -12,6 +12,8 @@ const ( ) func TestNew(t *testing.T) { + t.Parallel() + cm := New() if len(cm.shards) != ShardCount { t.Errorf("Expected %d shards, got %d", ShardCount, len(cm.shards)) @@ -35,6 +37,8 @@ func TestNew(t *testing.T) { } func TestGetShardIndex(t *testing.T) { + t.Parallel() + tests := []struct { key string }{ @@ -46,6 +50,8 @@ func TestGetShardIndex(t *testing.T) { for _, tt := range tests { t.Run(tt.key, func(t *testing.T) { + t.Parallel() + index := getShardIndex(tt.key) if index >= ShardCount32 { t.Errorf("Shard index %d exceeds shard count %d", index, ShardCount32) @@ -55,6 +61,8 @@ func TestGetShardIndex(t *testing.T) { } func TestGetShard(t *testing.T) { + t.Parallel() + cm := New() shard := cm.GetShard("test") @@ -70,6 +78,8 @@ func TestGetShard(t *testing.T) { } func TestSetAndGet(t *testing.T) { + t.Parallel() + cm := New() item := &Item{ Value: "test_value", @@ -97,6 +107,8 @@ func TestSetAndGet(t *testing.T) { } func TestHas(t *testing.T) { + t.Parallel() + cm := New() item := &Item{ Value: "test_value", @@ -117,6 +129,8 @@ func TestHas(t *testing.T) { } func TestPop(t *testing.T) { + t.Parallel() + cm := New() item := &Item{ Value: "test_value", @@ -144,6 +158,8 @@ func TestPop(t *testing.T) { } func TestRemove(t *testing.T) { + t.Parallel() + cm := New() item := &Item{ Value: "test_value", @@ -162,6 +178,8 @@ func TestRemove(t *testing.T) { } func TestCount(t *testing.T) { + t.Parallel() + cm := New() if cm.Count() != 0 { @@ -197,6 +215,8 @@ func TestCount(t *testing.T) { // not increment the shard counter. Without the existence check, Count drifts // every time a key is overwritten. func TestCount_SetExistingKeyDoesNotIncrement(t *testing.T) { + t.Parallel() + cm := New() item := &Item{Value: "v", Expiration: time.Hour} @@ -231,6 +251,8 @@ func TestCount_SetExistingKeyDoesNotIncrement(t *testing.T) { } func TestIterBuffered(t *testing.T) { + t.Parallel() + cm := New() items := map[string]*Item{ "key1": {Value: "value1", Expiration: time.Hour}, @@ -263,6 +285,8 @@ func TestIterBuffered(t *testing.T) { } func TestClear(t *testing.T) { + t.Parallel() + cm := New() items := map[string]*Item{ "key1": {Value: "value1", Expiration: time.Hour}, @@ -287,39 +311,36 @@ func TestClear(t *testing.T) { } func TestConcurrentAccess(t *testing.T) { + t.Parallel() + cm := New() - wg := sync.WaitGroup{} + + var wg sync.WaitGroup // Concurrent writes for i := range concurrentWrites { - wg.Add(1) - - go func(i int) { - defer wg.Done() - + wg.Go(func() { item := &Item{ Value: i, Expiration: time.Hour, } - cm.Set(string(rune(i)), item) - }(i) + cm.Set(string(rune(i)), item) //nolint:gosec // test fixture, i bounded far below int32 max + }) } // Concurrent reads for i := range concurrentReads { - wg.Add(1) - - go func(i int) { - defer wg.Done() - - cm.Get(string(rune(i))) - }(i) + wg.Go(func() { + cm.Get(string(rune(i))) //nolint:gosec // test fixture, i bounded far below int32 max + }) } wg.Wait() } func TestSnapshotPanic(t *testing.T) { + t.Parallel() + defer func() { if r := recover(); r == nil { t.Error("Expected panic for uninitialized ConcurrentMap") diff --git a/pkg/eviction/arc_test.go b/pkg/eviction/arc_test.go index cfacef7..01eb9bf 100644 --- a/pkg/eviction/arc_test.go +++ b/pkg/eviction/arc_test.go @@ -3,6 +3,8 @@ package eviction import "testing" func TestARC_BasicSetGetAndEvict(t *testing.T) { + t.Parallel() + arc, err := NewARCAlgorithm(2) if err != nil { t.Fatalf("NewARCAlgorithm error: %v", err) @@ -11,8 +13,10 @@ func TestARC_BasicSetGetAndEvict(t *testing.T) { arc.Set("a", 1) arc.Set("b", 2) - if v, ok := arc.Get("a"); !ok || v.(int) != 1 { - t.Fatalf("expected a=1, got %v ok=%v", v, ok) + if v, ok := arc.Get("a"); !ok { + t.Fatalf("expected a present, got ok=%v", ok) + } else if got, ok := v.(int); !ok || got != 1 { + t.Fatalf("expected a=1, got %v", v) } // Insert c, causing an eviction @@ -27,6 +31,8 @@ func TestARC_BasicSetGetAndEvict(t *testing.T) { } func TestARC_ZeroCapacity_NoOp(t *testing.T) { + t.Parallel() + arc, err := NewARCAlgorithm(0) if err != nil { t.Fatalf("NewARCAlgorithm error: %v", err) @@ -44,6 +50,8 @@ func TestARC_ZeroCapacity_NoOp(t *testing.T) { } func TestARC_Delete_RemovesResidentAndGhost(t *testing.T) { + t.Parallel() + arc, err := NewARCAlgorithm(2) if err != nil { t.Fatalf("NewARCAlgorithm error: %v", err) @@ -63,6 +71,8 @@ func TestARC_Delete_RemovesResidentAndGhost(t *testing.T) { } func TestARC_B1GhostHitPromotesToT2(t *testing.T) { + t.Parallel() + arc, err := NewARCAlgorithm(2) if err != nil { t.Fatalf("NewARCAlgorithm error: %v", err) @@ -75,7 +85,9 @@ func TestARC_B1GhostHitPromotesToT2(t *testing.T) { // Now reinsert one of the early keys to hit a ghost (B1/B2) path arc.Set("a", 10) - if v, ok := arc.Get("a"); !ok || v.(int) != 10 { - t.Fatalf("expected updated a=10 resident after B1/B2 hit, got %v ok=%v", v, ok) + if v, ok := arc.Get("a"); !ok { + t.Fatalf("expected a present after B1/B2 hit, got ok=%v", ok) + } else if got, ok := v.(int); !ok || got != 10 { + t.Fatalf("expected updated a=10 resident after B1/B2 hit, got %v", v) } } diff --git a/pkg/eviction/cawolfu.go b/pkg/eviction/cawolfu.go index f491fa5..b087e94 100644 --- a/pkg/eviction/cawolfu.go +++ b/pkg/eviction/cawolfu.go @@ -4,17 +4,21 @@ import ( "sync" "github.com/hyp3rd/hypercache/internal/sentinel" - "github.com/hyp3rd/hypercache/pkg/cache" ) // CAWOLFU is an eviction algorithm that uses the Cache-Aware Write-Optimized LFU (CAWOLFU) policy to select items for eviction. +// +// Concurrency: every method acquires c.mutex for the duration of the +// operation, so the items map needs no internal concurrency machinery — +// a plain map is sufficient. Previously this used pkg/cache.ConcurrentMap +// (v1) whose shard locks were redundant under cawolfu's own mutex. type CAWOLFU struct { - mutex sync.Mutex // protects all CAWOLFU operations - items cache.ConcurrentMap[string, *CAWOLFUNode] // concurrent map to store the items in the cache - list *CAWOLFULinkedList // linked list to store the items in the cache, with the most frequently used items at the front - length int // number of items in the cache - cap int // capacity of the cache - nodePool sync.Pool // pool of CAWOLFUNode values for memory reuse + mutex sync.Mutex // protects all CAWOLFU operations + items map[string]*CAWOLFUNode // map to store the items in the cache + list *CAWOLFULinkedList // linked list to store the items in the cache, with the most frequently used items at the front + length int // number of items in the cache + cap int // capacity of the cache + nodePool sync.Pool // pool of CAWOLFUNode values for memory reuse } // CAWOLFUNode is a struct that represents a node in the linked list. It has a key, value, and access count field. @@ -39,7 +43,7 @@ func NewCAWOLFU(capacity int) (*CAWOLFU, error) { } return &CAWOLFU{ - items: cache.New[*CAWOLFUNode](), + items: make(map[string]*CAWOLFUNode), list: &CAWOLFULinkedList{}, cap: capacity, nodePool: sync.Pool{ @@ -62,23 +66,16 @@ func (c *CAWOLFU) Evict() (string, bool) { node := c.list.tail c.list.remove(node) - err := c.items.Remove(node.key) - if err == nil { - c.length-- - - // Preserve key before resetting the node for pool reuse - evictedKey := node.key - resetCAWOLFUNode(node) - c.nodePool.Put(node) + delete(c.items, node.key) - return evictedKey, true - } + c.length-- - // If map/list out of sync, forcibly clean up + // Preserve key before resetting the node for pool reuse + evictedKey := node.key resetCAWOLFUNode(node) c.nodePool.Put(node) - return "", false + return evictedKey, true } // Set adds a new item to the cache with the given key. @@ -92,7 +89,7 @@ func (c *CAWOLFU) Set(key string, value any) { } // If key exists, update value and count, move to front - if node, ok := c.items.Get(key); ok { + if node, ok := c.items[key]; ok { node.value = value node.count++ c.moveToFront(node) @@ -106,19 +103,18 @@ func (c *CAWOLFU) Set(key string, value any) { return } - node := c.list.tail - c.list.remove(node) + evicted := c.list.tail + c.list.remove(evicted) - err := c.items.Remove(node.key) - if err == nil { - c.length-- - } + delete(c.items, evicted.key) - // always recycle node - resetCAWOLFUNode(node) - c.nodePool.Put(node) + c.length-- + + evictedKey := evicted.key + resetCAWOLFUNode(evicted) + c.nodePool.Put(evicted) - if node.key == key { // same key evicted, abort insert + if evictedKey == key { // same key evicted, abort insert return } } @@ -131,7 +127,7 @@ func (c *CAWOLFU) Set(key string, value any) { node.key = key node.value = value node.count = 1 - c.items.Set(key, node) + c.items[key] = node c.addToFront(node) c.length++ @@ -142,7 +138,7 @@ func (c *CAWOLFU) Get(key string) (any, bool) { c.mutex.Lock() defer c.mutex.Unlock() - node, ok := c.items.Get(key) + node, ok := c.items[key] if !ok { return nil, false } @@ -182,17 +178,13 @@ func (c *CAWOLFU) Delete(key string) { c.mutex.Lock() defer c.mutex.Unlock() - node, ok := c.items.Get(key) + node, ok := c.items[key] if !ok { return } c.list.remove(node) - - err := c.items.Remove(key) - if err != nil { - return - } + delete(c.items, key) c.length-- diff --git a/pkg/eviction/cawolfu_test.go b/pkg/eviction/cawolfu_test.go index a4e723c..7d50216 100644 --- a/pkg/eviction/cawolfu_test.go +++ b/pkg/eviction/cawolfu_test.go @@ -3,6 +3,8 @@ package eviction import "testing" func TestCAWOLFU_EvictsLeastFrequentTail(t *testing.T) { + t.Parallel() + c, err := NewCAWOLFU(2) if err != nil { t.Fatalf("NewCAWOLFU error: %v", err) @@ -27,12 +29,16 @@ func TestCAWOLFU_EvictsLeastFrequentTail(t *testing.T) { t.Fatalf("expected 'a' to remain in cache") } - if v, ok := c.Get("c"); !ok || v.(int) != 3 { - t.Fatalf("expected 'c'=3 in cache, got %v, ok=%v", v, ok) + if v, ok := c.Get("c"); !ok { + t.Fatalf("expected 'c' present, got ok=%v", ok) + } else if got, ok := v.(int); !ok || got != 3 { + t.Fatalf("expected 'c'=3 in cache, got %v", v) } } func TestCAWOLFU_EvictMethodOrder(t *testing.T) { + t.Parallel() + c, err := NewCAWOLFU(2) if err != nil { t.Fatalf("NewCAWOLFU error: %v", err) @@ -54,6 +60,8 @@ func TestCAWOLFU_EvictMethodOrder(t *testing.T) { } func TestCAWOLFU_ZeroCapacity_NoOp(t *testing.T) { + t.Parallel() + c, err := NewCAWOLFU(0) if err != nil { t.Fatalf("NewCAWOLFU error: %v", err) @@ -71,6 +79,8 @@ func TestCAWOLFU_ZeroCapacity_NoOp(t *testing.T) { } func TestCAWOLFU_Delete_RemovesItem(t *testing.T) { + t.Parallel() + c, err := NewCAWOLFU(2) if err != nil { t.Fatalf("NewCAWOLFU error: %v", err) diff --git a/pkg/eviction/clock_test.go b/pkg/eviction/clock_test.go index bb93ce4..41d72d2 100644 --- a/pkg/eviction/clock_test.go +++ b/pkg/eviction/clock_test.go @@ -3,6 +3,8 @@ package eviction import "testing" func TestClock_EvictsWhenHandFindsColdPage(t *testing.T) { + t.Parallel() + clk, err := NewClockAlgorithm(2) if err != nil { t.Fatalf("NewClockAlgorithm error: %v", err) @@ -46,6 +48,8 @@ func TestClock_EvictsWhenHandFindsColdPage(t *testing.T) { } func TestClock_ZeroCapacity_NoOp(t *testing.T) { + t.Parallel() + clk, err := NewClockAlgorithm(0) if err != nil { t.Fatalf("NewClockAlgorithm error: %v", err) @@ -63,6 +67,8 @@ func TestClock_ZeroCapacity_NoOp(t *testing.T) { } func TestClock_Delete_RemovesItem(t *testing.T) { + t.Parallel() + clk, err := NewClockAlgorithm(2) if err != nil { t.Fatalf("NewClockAlgorithm error: %v", err) diff --git a/pkg/eviction/lfu_test.go b/pkg/eviction/lfu_test.go index b985cf5..1431e9b 100644 --- a/pkg/eviction/lfu_test.go +++ b/pkg/eviction/lfu_test.go @@ -4,6 +4,8 @@ import "testing" // Test that when two items have equal frequency, the older (least-recent) is evicted first. func TestLFU_EvictsOldestOnTie_InsertOrder(t *testing.T) { + t.Parallel() + lfu, err := NewLFUAlgorithm(2) if err != nil { t.Fatalf("NewLFUAlgorithm error: %v", err) @@ -35,6 +37,8 @@ func TestLFU_EvictsOldestOnTie_InsertOrder(t *testing.T) { // Test that recency is used to break ties after accesses with equalized frequency. func TestLFU_EvictsOldestOnTie_AccessOrder(t *testing.T) { + t.Parallel() + lfu, err := NewLFUAlgorithm(2) if err != nil { t.Fatalf("NewLFUAlgorithm error: %v", err) @@ -65,6 +69,8 @@ func TestLFU_EvictsOldestOnTie_AccessOrder(t *testing.T) { } func TestLFU_ZeroCapacity_NoOp(t *testing.T) { + t.Parallel() + lfu, err := NewLFUAlgorithm(0) if err != nil { t.Fatalf("NewLFUAlgorithm error: %v", err) @@ -82,6 +88,8 @@ func TestLFU_ZeroCapacity_NoOp(t *testing.T) { } func TestLFU_Delete_RemovesItem(t *testing.T) { + t.Parallel() + lfu, err := NewLFUAlgorithm(2) if err != nil { t.Fatalf("NewLFUAlgorithm error: %v", err) diff --git a/pkg/eviction/lru_test.go b/pkg/eviction/lru_test.go index 3dc1426..5a245fb 100644 --- a/pkg/eviction/lru_test.go +++ b/pkg/eviction/lru_test.go @@ -3,6 +3,8 @@ package eviction import "testing" func TestLRU_EvictsLeastRecentlyUsedOnSet(t *testing.T) { + t.Parallel() + lru, err := NewLRUAlgorithm(2) if err != nil { t.Fatalf("NewLRUAlgorithm error: %v", err) @@ -27,12 +29,16 @@ func TestLRU_EvictsLeastRecentlyUsedOnSet(t *testing.T) { t.Fatalf("expected 'a' to remain in cache") } - if v, ok := lru.Get("c"); !ok || v.(int) != 3 { - t.Fatalf("expected 'c'=3 in cache, got %v, ok=%v", v, ok) + if v, ok := lru.Get("c"); !ok { + t.Fatalf("expected 'c' present, got ok=%v", ok) + } else if got, ok := v.(int); !ok || got != 3 { + t.Fatalf("expected 'c'=3 in cache, got %v", v) } } func TestLRU_EvictMethodOrder(t *testing.T) { + t.Parallel() + lru, err := NewLRUAlgorithm(2) if err != nil { t.Fatalf("NewLRUAlgorithm error: %v", err) @@ -54,6 +60,8 @@ func TestLRU_EvictMethodOrder(t *testing.T) { } func TestLRU_ZeroCapacity_NoOp(t *testing.T) { + t.Parallel() + lru, err := NewLRUAlgorithm(0) if err != nil { t.Fatalf("NewLRUAlgorithm error: %v", err) @@ -71,6 +79,8 @@ func TestLRU_ZeroCapacity_NoOp(t *testing.T) { } func TestLRU_Delete_RemovesItem(t *testing.T) { + t.Parallel() + lru, err := NewLRUAlgorithm(2) if err != nil { t.Fatalf("NewLRUAlgorithm error: %v", err) diff --git a/pkg/stats/histogramcollector_test.go b/pkg/stats/histogramcollector_test.go index 75559b8..ee03385 100644 --- a/pkg/stats/histogramcollector_test.go +++ b/pkg/stats/histogramcollector_test.go @@ -10,6 +10,8 @@ import ( ) func TestHistogramStatsCollector_BasicAggregates(t *testing.T) { + t.Parallel() + c := NewHistogramStatsCollector() c.Incr(constants.StatIncr, 10) @@ -49,6 +51,8 @@ func TestHistogramStatsCollector_BasicAggregates(t *testing.T) { } func TestHistogramStatsCollector_DecrStoresNegative(t *testing.T) { + t.Parallel() + c := NewHistogramStatsCollector() c.Incr(constants.StatIncr, 5) @@ -69,6 +73,8 @@ func TestHistogramStatsCollector_DecrStoresNegative(t *testing.T) { } func TestHistogramStatsCollector_Median(t *testing.T) { + t.Parallel() + c := NewHistogramStatsCollector() for _, v := range []int64{3, 1, 4, 1, 5, 9, 2, 6} { @@ -89,6 +95,8 @@ func TestHistogramStatsCollector_Median(t *testing.T) { } func TestHistogramStatsCollector_Percentile(t *testing.T) { + t.Parallel() + c := NewHistogramStatsCollector() for i := int64(1); i <= 100; i++ { @@ -113,6 +121,8 @@ func TestHistogramStatsCollector_Percentile(t *testing.T) { } func TestHistogramStatsCollector_EmptyStat(t *testing.T) { + t.Parallel() + c := NewHistogramStatsCollector() // querying an unrecorded stat must return zero values, not panic @@ -134,11 +144,13 @@ func TestHistogramStatsCollector_EmptyStat(t *testing.T) { } func TestHistogramStatsCollector_BoundedSamples(t *testing.T) { - const cap = 8 + t.Parallel() - c := NewHistogramStatsCollectorWithCapacity(cap) + const windowCap = 8 - // Write more than cap samples; sample buffer must stay at cap. + c := NewHistogramStatsCollectorWithCapacity(windowCap) + + // Write more than windowCap samples; sample buffer must stay at windowCap. for i := range int64(100) { c.Histogram(constants.StatHistogram, i) } @@ -152,8 +164,8 @@ func TestHistogramStatsCollector_BoundedSamples(t *testing.T) { t.Errorf("lifetime Count = %d, want 100", stat.Count) } - if got := len(stat.Values); got != cap { - t.Errorf("len(Values) = %d, want %d (bounded)", got, cap) + if got := len(stat.Values); got != windowCap { + t.Errorf("len(Values) = %d, want %d (bounded)", got, windowCap) } // Min/Max must still reflect the lifetime range, not just the window. @@ -171,6 +183,8 @@ func TestHistogramStatsCollector_BoundedSamples(t *testing.T) { // trip the race detector (which the previous implementation would, since it // sorted shared backing arrays in-place under RLock). func TestHistogramStatsCollector_ConcurrentRecord(t *testing.T) { + t.Parallel() + c := NewHistogramStatsCollector() const writers = 8 @@ -224,6 +238,8 @@ func TestHistogramStatsCollector_ConcurrentRecord(t *testing.T) { // not mutate the live sample buffer. The previous implementation called // slices.Sort on the live buffer, racing with concurrent writers. func TestHistogramStatsCollector_GetStatsSnapshotIsolated(t *testing.T) { + t.Parallel() + c := NewHistogramStatsCollector() for i := range int64(32) { @@ -252,20 +268,22 @@ func TestHistogramStatsCollector_GetStatsSnapshotIsolated(t *testing.T) { // keeps memory usage flat under sustained recording. The previous // implementation appended forever and would grow unbounded. func TestHistogramStatsCollector_NoMemoryLeak(t *testing.T) { + t.Parallel() + if testing.Short() { t.Skip("skipping memory soak in short mode") } - const cap = 1024 + const windowCap = 1024 - c := NewHistogramStatsCollectorWithCapacity(cap) + c := NewHistogramStatsCollectorWithCapacity(windowCap) // Prime the ring buffer. - for i := range cap { + for i := range windowCap { c.Histogram(constants.StatHistogram, int64(i)) } - runtime.GC() + runtime.GC() //nolint:revive // intentional GC to take a clean heap reading for the leak assertion var before runtime.MemStats @@ -278,7 +296,7 @@ func TestHistogramStatsCollector_NoMemoryLeak(t *testing.T) { c.Histogram(constants.StatHistogram, int64(i)) } - runtime.GC() + runtime.GC() //nolint:revive // intentional GC to take a clean heap reading for the leak assertion var after runtime.MemStats @@ -288,14 +306,14 @@ func TestHistogramStatsCollector_NoMemoryLeak(t *testing.T) { // runtime overhead, GC bookkeeping, etc.). const slack = 1 << 20 - growth := int64(after.HeapAlloc) - int64(before.HeapAlloc) + growth := int64(after.HeapAlloc) - int64(before.HeapAlloc) //nolint:gosec // HeapAlloc never approaches int64 max in this test if growth > slack { t.Errorf("heap grew by %d bytes during steady-state recording (want <= %d)", growth, slack) } // And lifetime count is still exact. stat := c.GetStats()[constants.StatHistogram.String()] - want := int64(cap + extra) + want := int64(windowCap + extra) if int64(stat.Count) != want { t.Errorf("Count = %d, want %d", stat.Count, want) @@ -305,6 +323,8 @@ func TestHistogramStatsCollector_NoMemoryLeak(t *testing.T) { // TestHistogramStatsCollector_AtomicMinMaxRace exercises the CAS loops in // record() under concurrent extreme values. func TestHistogramStatsCollector_AtomicMinMaxRace(t *testing.T) { + t.Parallel() + c := NewHistogramStatsCollector() var wg sync.WaitGroup diff --git a/pool_test.go b/pool_test.go index 3cf6322..c2135de 100644 --- a/pool_test.go +++ b/pool_test.go @@ -10,6 +10,8 @@ import ( ) func TestWorkerPool_EnqueueAndShutdown(t *testing.T) { + t.Parallel() + pool := NewWorkerPool(3) var mu sync.Mutex @@ -38,6 +40,8 @@ func TestWorkerPool_EnqueueAndShutdown(t *testing.T) { } func TestWorkerPool_JobErrorHandling(t *testing.T) { + t.Parallel() + pool := NewWorkerPool(2) expectedErr := ewrap.New("job error") pool.Enqueue(func() error { @@ -66,6 +70,8 @@ func TestWorkerPool_JobErrorHandling(t *testing.T) { } func TestWorkerPool_ResizeIncrease(t *testing.T) { + t.Parallel() + pool := NewWorkerPool(1) var mu sync.Mutex @@ -94,6 +100,8 @@ func TestWorkerPool_ResizeIncrease(t *testing.T) { } func TestWorkerPool_ResizeDecrease(t *testing.T) { + t.Parallel() + pool := NewWorkerPool(4) var mu sync.Mutex @@ -122,6 +130,8 @@ func TestWorkerPool_ResizeDecrease(t *testing.T) { } func TestWorkerPool_ResizeToZeroAndBack(t *testing.T) { + t.Parallel() + pool := NewWorkerPool(2) done := make(chan struct{}) called := false @@ -152,6 +162,8 @@ func TestWorkerPool_ResizeToZeroAndBack(t *testing.T) { } func TestWorkerPool_NegativeResizeDoesNothing(t *testing.T) { + t.Parallel() + pool := NewWorkerPool(2) pool.Resize(-1) @@ -170,6 +182,8 @@ func TestWorkerPool_NegativeResizeDoesNothing(t *testing.T) { // schedule work mid-shutdown. The new contract trades a "loud failure on // programming error" for "no panics during graceful shutdown races.". func TestWorkerPool_EnqueueAfterShutdownDrops(t *testing.T) { + t.Parallel() + pool := NewWorkerPool(1) pool.Shutdown() diff --git a/tests/benchmark/hypercache_concurrency_benchmark_test.go b/tests/benchmark/hypercache_concurrency_benchmark_test.go index b2cc20b..f49e820 100644 --- a/tests/benchmark/hypercache_concurrency_benchmark_test.go +++ b/tests/benchmark/hypercache_concurrency_benchmark_test.go @@ -1,4 +1,4 @@ -package tests +package benchmark import ( "context" diff --git a/tests/benchmark/hypercache_get_benchmark_test.go b/tests/benchmark/hypercache_get_benchmark_test.go index 288f353..1446f52 100644 --- a/tests/benchmark/hypercache_get_benchmark_test.go +++ b/tests/benchmark/hypercache_get_benchmark_test.go @@ -1,4 +1,4 @@ -package tests +package benchmark import ( "context" diff --git a/tests/benchmark/hypercache_list_benchmark_test.go b/tests/benchmark/hypercache_list_benchmark_test.go index 95fe086..ebecd3a 100644 --- a/tests/benchmark/hypercache_list_benchmark_test.go +++ b/tests/benchmark/hypercache_list_benchmark_test.go @@ -1,4 +1,4 @@ -package tests +package benchmark import ( "context" diff --git a/tests/benchmark/hypercache_set_benchmark_test.go b/tests/benchmark/hypercache_set_benchmark_test.go index b2abebf..51c6b75 100644 --- a/tests/benchmark/hypercache_set_benchmark_test.go +++ b/tests/benchmark/hypercache_set_benchmark_test.go @@ -1,4 +1,4 @@ -package tests +package benchmark import ( "context" diff --git a/tests/hypercache_distmemory_heartbeat_sampling_test.go b/tests/hypercache_distmemory_heartbeat_sampling_test.go index 81e7a91..e50be35 100644 --- a/tests/hypercache_distmemory_heartbeat_sampling_test.go +++ b/tests/hypercache_distmemory_heartbeat_sampling_test.go @@ -88,7 +88,11 @@ func TestHeartbeatSamplingAndTransitions(t *testing.T) { //nolint:paralleltest snap := b1.DistMembershipSnapshot() verAny := snap["version"] - ver, _ := verAny.(uint64) + ver, ok := verAny.(uint64) + if !ok { + t.Fatalf("expected version to be uint64, got %T (%v)", verAny, verAny) + } + if ver < 3 { // initial upserts already increment version; tolerate timing variance t.Fatalf("expected membership version >=4, got %v", verAny) } diff --git a/tests/hypercache_distmemory_hinted_handoff_test.go b/tests/hypercache_distmemory_hinted_handoff_test.go index ef54596..e552b90 100644 --- a/tests/hypercache_distmemory_hinted_handoff_test.go +++ b/tests/hypercache_distmemory_hinted_handoff_test.go @@ -15,6 +15,8 @@ import ( // TestHintedHandoffReplay ensures that when a replica is down during a write, a hint is queued and later replayed. func TestHintedHandoffReplay(t *testing.T) { + t.Parallel() + ctx := context.Background() transport := backend.NewInProcessTransport() @@ -36,8 +38,15 @@ func TestHintedHandoffReplay(t *testing.T) { primary, _ := backend.NewDistMemory(ctx, primaryOpts...) replica, _ := backend.NewDistMemory(ctx, replicaOpts...) - p := any(primary).(*backend.DistMemory) - r := any(replica).(*backend.DistMemory) + p, ok := any(primary).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast primary to *backend.DistMemory") + } + + r, ok := any(replica).(*backend.DistMemory) + if !ok { + t.Fatalf("failed to cast replica to *backend.DistMemory") + } StopOnCleanup(t, p) StopOnCleanup(t, r) diff --git a/tests/hypercache_distmemory_integration_test.go b/tests/hypercache_distmemory_integration_test.go index 32509f6..c935e0c 100644 --- a/tests/hypercache_distmemory_integration_test.go +++ b/tests/hypercache_distmemory_integration_test.go @@ -13,6 +13,8 @@ import ( // TestDistMemoryForwardingReplication spins up two DistMemory backends sharing membership and transport // then ensures ownership, forwarding and replication semantics hold. func TestDistMemoryForwardingReplication(t *testing.T) { + t.Parallel() + ring := cluster.NewRing(cluster.WithReplication(2)) membership := cluster.NewMembership(ring) transport := backend.NewInProcessTransport() diff --git a/tests/hypercache_distmemory_remove_readrepair_test.go b/tests/hypercache_distmemory_remove_readrepair_test.go index f0d2324..9cf1240 100644 --- a/tests/hypercache_distmemory_remove_readrepair_test.go +++ b/tests/hypercache_distmemory_remove_readrepair_test.go @@ -10,6 +10,8 @@ import ( ) // helper to build two-node replicated cluster. +// +//nolint:revive // confusing-results: two backends + ring is the natural shape; named returns add no clarity here func newTwoNodeCluster(t *testing.T) (*backend.DistMemory, *backend.DistMemory, *cluster.Ring) { t.Helper() @@ -50,6 +52,8 @@ func newTwoNodeCluster(t *testing.T) (*backend.DistMemory, *backend.DistMemory, // TestDistMemoryRemoveReplication ensures that Remove replicates deletions across replicas. func TestDistMemoryRemoveReplication(t *testing.T) { + t.Parallel() + b1, b2, ring := newTwoNodeCluster(t) key := "remove-key" @@ -111,6 +115,8 @@ func TestDistMemoryRemoveReplication(t *testing.T) { // TestDistMemoryReadRepair ensures a stale replica is healed on read. func TestDistMemoryReadRepair(t *testing.T) { + t.Parallel() + b1, b2, ring := newTwoNodeCluster(t) key := "rr-key" diff --git a/tests/hypercache_distmemory_stale_quorum_test.go b/tests/hypercache_distmemory_stale_quorum_test.go index 4db4b8a..8c7e31b 100644 --- a/tests/hypercache_distmemory_stale_quorum_test.go +++ b/tests/hypercache_distmemory_stale_quorum_test.go @@ -12,6 +12,8 @@ import ( // TestDistMemoryStaleQuorum ensures quorum read returns newest version and repairs stale replicas. func TestDistMemoryStaleQuorum(t *testing.T) { + t.Parallel() + ring := cluster.NewRing(cluster.WithReplication(3)) membership := cluster.NewMembership(ring) transport := backend.NewInProcessTransport() diff --git a/tests/hypercache_distmemory_write_quorum_test.go b/tests/hypercache_distmemory_write_quorum_test.go index 5b1eb0d..c744c1a 100644 --- a/tests/hypercache_distmemory_write_quorum_test.go +++ b/tests/hypercache_distmemory_write_quorum_test.go @@ -15,6 +15,8 @@ import ( // TestWriteQuorumSuccess ensures a QUORUM write succeeds with majority acks. func TestWriteQuorumSuccess(t *testing.T) { + t.Parallel() + ctx := context.Background() transport := backend.NewInProcessTransport() @@ -74,6 +76,8 @@ func TestWriteQuorumSuccess(t *testing.T) { // TestWriteQuorumFailure ensures ALL consistency fails when not enough acks. func TestWriteQuorumFailure(t *testing.T) { + t.Parallel() + ctx := context.Background() transport := backend.NewInProcessTransport() @@ -125,7 +129,7 @@ func TestWriteQuorumFailure(t *testing.T) { da.SetTransport(transport) db.SetTransport(transport) transport.Register(da) - transport.Register(db) // C intentionally not registered (unreachable) + transport.Register(db) // C intentionally left unregistered to simulate it being offline // Find a key whose owners include all three nodes (replication=3 ensures this) – just brute force until order stable. key := "quorum-all-fail" diff --git a/tests/hypercache_get_multiple_test.go b/tests/hypercache_get_multiple_test.go index f689f88..3936d56 100644 --- a/tests/hypercache_get_multiple_test.go +++ b/tests/hypercache_get_multiple_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/longbridgeapp/assert" + "github.com/stretchr/testify/require" "github.com/hyp3rd/hypercache" "github.com/hyp3rd/hypercache/internal/constants" @@ -14,6 +14,8 @@ import ( ) func TestGetMultiple(t *testing.T) { + t.Parallel() + tests := []struct { name string keys []string @@ -27,9 +29,9 @@ func TestGetMultiple(t *testing.T) { wantValues: map[string]any{"key1": 1, "key2": 2, "key3": 3}, wantErrs: map[string]error{}, setup: func(cache *hypercache.HyperCache[backend.InMemory]) { - cache.Set(context.TODO(), "key1", 1, 0) - cache.Set(context.TODO(), "key2", 2, 0) - cache.Set(context.TODO(), "key3", 3, 0) + _ = cache.Set(context.TODO(), "key1", 1, 0) + _ = cache.Set(context.TODO(), "key2", 2, 0) + _ = cache.Set(context.TODO(), "key3", 3, 0) }, }, { @@ -38,8 +40,8 @@ func TestGetMultiple(t *testing.T) { wantValues: map[string]any{"key1": 1, "key3": 3}, wantErrs: map[string]error{"key2": sentinel.ErrKeyNotFound}, setup: func(cache *hypercache.HyperCache[backend.InMemory]) { - cache.Set(context.TODO(), "key1", 1, 0) - cache.Set(context.TODO(), "key3", 3, 0) + _ = cache.Set(context.TODO(), "key1", 1, 0) + _ = cache.Set(context.TODO(), "key3", 3, 0) }, }, { @@ -48,16 +50,19 @@ func TestGetMultiple(t *testing.T) { wantValues: map[string]any{"key2": 2, "key3": 3}, wantErrs: map[string]error{"key1": sentinel.ErrKeyNotFound}, setup: func(cache *hypercache.HyperCache[backend.InMemory]) { - cache.Set(context.TODO(), "key1", 1, time.Millisecond) + _ = cache.Set(context.TODO(), "key1", 1, time.Millisecond) time.Sleep(2 * time.Millisecond) - cache.Set(context.TODO(), "key2", 2, 0) - cache.Set(context.TODO(), "key3", 3, 0) + + _ = cache.Set(context.TODO(), "key2", 2, 0) + _ = cache.Set(context.TODO(), "key3", 3, 0) }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { + t.Parallel() + config := &hypercache.Config[backend.InMemory]{ BackendType: constants.InMemoryBackend, HyperCacheOptions: []hypercache.Option[backend.InMemory]{ @@ -71,12 +76,12 @@ func TestGetMultiple(t *testing.T) { hypercache.GetDefaultManager() cache, err := hypercache.New(context.TODO(), hypercache.GetDefaultManager(), config) - assert.Nil(t, err) + require.NoError(t, err) test.setup(cache) gotValues, gotErrs := cache.GetMultiple(context.TODO(), test.keys...) - assert.Equal(t, test.wantValues, gotValues) - assert.Equal(t, test.wantErrs, gotErrs) + require.Equal(t, test.wantValues, gotValues) + require.Equal(t, test.wantErrs, gotErrs) }) } } diff --git a/tests/hypercache_get_or_set_test.go b/tests/hypercache_get_or_set_test.go index c77747e..5ecac62 100644 --- a/tests/hypercache_get_or_set_test.go +++ b/tests/hypercache_get_or_set_test.go @@ -6,13 +6,13 @@ import ( "testing" "time" - "github.com/longbridgeapp/assert" + "github.com/stretchr/testify/require" "github.com/hyp3rd/hypercache" "github.com/hyp3rd/hypercache/internal/sentinel" ) -func TestHyperCache_GetOrSet(t *testing.T) { +func TestHyperCache_GetOrSet(t *testing.T) { //nolint:paralleltest // subtests share cache instance and depend on insertion order tests := []struct { name string key string @@ -71,9 +71,9 @@ func TestHyperCache_GetOrSet(t *testing.T) { }, } cache, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 10) - assert.Nil(t, err) + require.NoError(t, err) - for _, test := range tests { + for _, test := range tests { //nolint:paralleltest // subtests share cache instance t.Run(test.name, func(t *testing.T) { var ( val any @@ -84,11 +84,11 @@ func TestHyperCache_GetOrSet(t *testing.T) { val, err = cache.GetOrSet(context.TODO(), test.key, test.value, test.expiry) if !shouldExpire { - assert.Equal(t, test.expectedErr, err) + require.Equal(t, test.expectedErr, err) } if err == nil && !shouldExpire { - assert.Equal(t, test.expectedValue, val) + require.Equal(t, test.expectedValue, val) } if shouldExpire { @@ -96,18 +96,18 @@ func TestHyperCache_GetOrSet(t *testing.T) { time.Sleep(2 * time.Millisecond) _, err = cache.GetOrSet(context.TODO(), test.key, test.value, test.expiry) - assert.Equal(t, test.expectedErr, err) + require.Equal(t, test.expectedErr, err) } // Check if the value has been set in the cache if err == nil { val, ok := cache.Get(context.TODO(), test.key) - assert.True(t, ok) - assert.Equal(t, test.expectedValue, val) + require.True(t, ok) + require.Equal(t, test.expectedValue, val) } else { val, ok := cache.Get(context.TODO(), test.key) - assert.False(t, ok) - assert.Nil(t, val) + require.False(t, ok) + require.Nil(t, val) } }) } diff --git a/tests/hypercache_get_test.go b/tests/hypercache_get_test.go index de80de6..1912e14 100644 --- a/tests/hypercache_get_test.go +++ b/tests/hypercache_get_test.go @@ -5,13 +5,13 @@ import ( "testing" "time" - "github.com/longbridgeapp/assert" + "github.com/stretchr/testify/require" "github.com/hyp3rd/hypercache" "github.com/hyp3rd/hypercache/internal/sentinel" ) -func TestHyperCache_Get(t *testing.T) { +func TestHyperCache_Get(t *testing.T) { //nolint:paralleltest // subtests share cache instance tests := []struct { name string key string @@ -66,14 +66,14 @@ func TestHyperCache_Get(t *testing.T) { }, } cache, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 10) - assert.Nil(t, err) + require.NoError(t, err) - for _, test := range tests { + for _, test := range tests { //nolint:paralleltest // subtests share cache instance t.Run(test.name, func(t *testing.T) { if test.shouldSet { err = cache.Set(context.TODO(), test.key, test.value, test.expiry) if err != nil { - assert.Equal(t, test.expectedErr, err) + require.Equal(t, test.expectedErr, err) } if test.sleep > 0 { @@ -83,10 +83,10 @@ func TestHyperCache_Get(t *testing.T) { val, ok := cache.Get(context.TODO(), test.key) if test.expectedErr != nil || !ok { - assert.False(t, ok) + require.False(t, ok) } else { - assert.True(t, ok) - assert.Equal(t, test.expectedValue, val) + require.True(t, ok) + require.Equal(t, test.expectedValue, val) } }) } diff --git a/tests/hypercache_http_merkle_test.go b/tests/hypercache_http_merkle_test.go index caeddb0..d34672a 100644 --- a/tests/hypercache_http_merkle_test.go +++ b/tests/hypercache_http_merkle_test.go @@ -13,6 +13,8 @@ import ( // TestHTTPFetchMerkle ensures HTTP transport can fetch a remote Merkle tree and SyncWith works over HTTP. func TestHTTPFetchMerkle(t *testing.T) { + t.Parallel() + ctx := context.Background() // shared ring/membership @@ -89,8 +91,13 @@ func TestHTTPFetchMerkle(t *testing.T) { deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { - resp, err := http.Get("http://" + b1.LocalNodeAddr() + "/internal/merkle") - if err == nil { + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+b1.LocalNodeAddr()+"/internal/merkle", nil) + if reqErr != nil { + t.Fatalf("build merkle request: %v", reqErr) + } + + resp, getErr := http.DefaultClient.Do(req) + if getErr == nil { _ = resp.Body.Close() if resp.StatusCode == http.StatusOK { @@ -131,4 +138,6 @@ func TestHTTPFetchMerkle(t *testing.T) { } } -func httpKey(i int) string { return "hkey:" + string(rune('a'+i)) } +func httpKey(i int) string { + return "hkey:" + string(rune('a'+i)) //nolint:gosec // test fixture, i bounded by 5 in caller +} diff --git a/tests/hypercache_mgmt_dist_test.go b/tests/hypercache_mgmt_dist_test.go index 11d7c8c..ecf5b5a 100644 --- a/tests/hypercache_mgmt_dist_test.go +++ b/tests/hypercache_mgmt_dist_test.go @@ -139,7 +139,8 @@ func getJSON(t *testing.T, url string) map[string]any { if err != nil { t.Fatalf("GET %s: %v", url, err) } - defer resp.Body.Close() + + defer func() { _ = resp.Body.Close() }() b, err := io.ReadAll(resp.Body) if err != nil { diff --git a/tests/hypercache_set_test.go b/tests/hypercache_set_test.go index d17fe18..f5b14bb 100644 --- a/tests/hypercache_set_test.go +++ b/tests/hypercache_set_test.go @@ -5,13 +5,13 @@ import ( "testing" "time" - "github.com/longbridgeapp/assert" + "github.com/stretchr/testify/require" "github.com/hyp3rd/hypercache" "github.com/hyp3rd/hypercache/internal/sentinel" ) -func TestHyperCache_Set(t *testing.T) { +func TestHyperCache_Set(t *testing.T) { //nolint:paralleltest // subtests share cache instance and depend on insertion order tests := []struct { name string key string @@ -70,19 +70,19 @@ func TestHyperCache_Set(t *testing.T) { }, } cache, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 10) - assert.Nil(t, err) + require.NoError(t, err) - defer cache.Stop(context.TODO()) + t.Cleanup(func() { _ = cache.Stop(context.TODO()) }) - for _, test := range tests { + for _, test := range tests { //nolint:paralleltest // subtests share cache instance t.Run(test.name, func(t *testing.T) { err = cache.Set(context.TODO(), test.key, test.value, test.expiry) - assert.Equal(t, test.expectedErr, err) + require.Equal(t, test.expectedErr, err) if err == nil { val, ok := cache.Get(context.TODO(), test.key) - assert.True(t, ok) - assert.Equal(t, test.expectedValue, val) + require.True(t, ok) + require.Equal(t, test.expectedValue, val) } }) } diff --git a/tests/hypercache_trigger_eviction_test.go b/tests/hypercache_trigger_eviction_test.go index f975502..c1228ae 100644 --- a/tests/hypercache_trigger_eviction_test.go +++ b/tests/hypercache_trigger_eviction_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/longbridgeapp/assert" + "github.com/stretchr/testify/require" "github.com/hyp3rd/hypercache" "github.com/hyp3rd/hypercache/pkg/backend" @@ -13,17 +13,19 @@ import ( // Test TriggerEviction when evictionInterval == 0 triggers immediate eviction of overflow item(s). func TestHyperCache_TriggerEviction_Immediate(t *testing.T) { + t.Parallel() + hc, err := hypercache.NewInMemoryWithDefaults(context.TODO(), 1) - assert.Nil(t, err) + require.NoError(t, err) - defer hc.Stop(context.TODO()) + t.Cleanup(func() { _ = hc.Stop(context.TODO()) }) // Set eviction interval to zero; eviction loop will run on manual trigger hypercache.ApplyHyperCacheOptions(hc, hypercache.WithEvictionInterval[backend.InMemory](0)) // Add two items beyond capacity to force eviction need - assert.Nil(t, hc.Set(context.TODO(), "k1", "v1", 0)) - assert.Nil(t, hc.Set(context.TODO(), "k2", "v2", 0)) + require.NoError(t, hc.Set(context.TODO(), "k1", "v1", 0)) + require.NoError(t, hc.Set(context.TODO(), "k2", "v2", 0)) // Without waiting, trigger eviction explicitly (non-blocking) // Rapid fire triggers should be non-blocking @@ -44,5 +46,5 @@ func TestHyperCache_TriggerEviction_Immediate(t *testing.T) { time.Sleep(20 * time.Millisecond) } - assert.True(t, hc.Count(context.TODO()) <= 1) + require.LessOrEqual(t, hc.Count(context.TODO()), 1) } diff --git a/tests/integration/dist_phase1_test.go b/tests/integration/dist_phase1_test.go index 2583387..d751106 100644 --- a/tests/integration/dist_phase1_test.go +++ b/tests/integration/dist_phase1_test.go @@ -17,7 +17,9 @@ import ( func allocatePort(tb testing.TB) string { tb.Helper() - l, err := net.Listen("tcp", "127.0.0.1:0") + var lc net.ListenConfig + + l, err := lc.Listen(context.Background(), "tcp", "127.0.0.1:0") if err != nil { tb.Fatalf("listen: %v", err) } @@ -31,6 +33,8 @@ func allocatePort(tb testing.TB) string { // TestDistPhase1BasicQuorum is a scaffolding test verifying three-node quorum Set/Get over HTTP transport. func TestDistPhase1BasicQuorum(t *testing.T) { + t.Parallel() + ctx := context.Background() addrA := allocatePort(t) @@ -120,7 +124,8 @@ func valueOK(v any) bool { //nolint:ireturn } if s := string(x); s == "djE=" { // base64 of v1 - if b, err := base64.StdEncoding.DecodeString(s); err == nil && string(b) == "v1" { + b, err := base64.StdEncoding.DecodeString(s) + if err == nil && string(b) == "v1" { return true } } @@ -133,7 +138,8 @@ func valueOK(v any) bool { //nolint:ireturn } if x == "djE=" { // base64 form - if b, err := base64.StdEncoding.DecodeString(x); err == nil && string(b) == "v1" { + b, err := base64.StdEncoding.DecodeString(x) + if err == nil && string(b) == "v1" { return true } } @@ -156,7 +162,8 @@ func valueOK(v any) bool { //nolint:ireturn } if s == "djE=" { - if b, err2 := base64.StdEncoding.DecodeString(s); err2 == nil && string(b) == "v1" { + b, err2 := base64.StdEncoding.DecodeString(s) + if err2 == nil && string(b) == "v1" { return true } } diff --git a/tests/integration/dist_rebalance_leave_test.go b/tests/integration/dist_rebalance_leave_test.go index 8d2e048..98e8bfa 100644 --- a/tests/integration/dist_rebalance_leave_test.go +++ b/tests/integration/dist_rebalance_leave_test.go @@ -11,6 +11,8 @@ import ( // TestDistRebalanceLeave verifies keys are redistributed after a node leaves. func TestDistRebalanceLeave(t *testing.T) { + t.Parallel() + ctx := context.Background() // Start 3 nodes. diff --git a/tests/integration/dist_rebalance_replica_diff_test.go b/tests/integration/dist_rebalance_replica_diff_test.go index 4db58e8..6c18db0 100644 --- a/tests/integration/dist_rebalance_replica_diff_test.go +++ b/tests/integration/dist_rebalance_replica_diff_test.go @@ -12,13 +12,15 @@ import ( // TestDistRebalanceReplicaDiff ensures that when a new replica is added (primary unchanged) // the new replica eventually receives the keys via replica-only diff replication. func TestDistRebalanceReplicaDiff(t *testing.T) { + t.Parallel() + ctx := context.Background() // Start with two nodes replication=2 so both are owners for each key. addrA := allocatePort(t) addrB := allocatePort(t) - baseOpts := []backend.DistMemoryOption{ + baseOpts := []backend.DistMemoryOption{ //nolint:prealloc // literal options; final size depends on test branches backend.WithDistReplication(2), backend.WithDistVirtualNodes(32), backend.WithDistRebalanceInterval(120 * time.Millisecond), diff --git a/tests/integration/dist_rebalance_replica_diff_throttle_test.go b/tests/integration/dist_rebalance_replica_diff_throttle_test.go index 30f1cf6..f70738b 100644 --- a/tests/integration/dist_rebalance_replica_diff_throttle_test.go +++ b/tests/integration/dist_rebalance_replica_diff_throttle_test.go @@ -11,13 +11,15 @@ import ( // TestDistRebalanceReplicaDiffThrottle ensures the per-tick limit increments throttle metric. func TestDistRebalanceReplicaDiffThrottle(t *testing.T) { + t.Parallel() + ctx := context.Background() addrA := allocatePort(t) addrB := allocatePort(t) // Low rebalance interval & strict replica diff limit of 1 per tick to force throttle. - base := []backend.DistMemoryOption{ + base := []backend.DistMemoryOption{ //nolint:prealloc // literal options; final size depends on test branches backend.WithDistReplication(2), backend.WithDistVirtualNodes(16), backend.WithDistRebalanceInterval(80 * time.Millisecond), diff --git a/tests/integration/dist_rebalance_test.go b/tests/integration/dist_rebalance_test.go index 6dd4846..5072d14 100644 --- a/tests/integration/dist_rebalance_test.go +++ b/tests/integration/dist_rebalance_test.go @@ -2,6 +2,7 @@ package integration import ( "context" + "errors" "fmt" "io" "net/http" @@ -13,8 +14,14 @@ import ( cache "github.com/hyp3rd/hypercache/pkg/cache/v2" ) +// errUnexpectedStatus is the sentinel returned when the dist node health +// endpoint reports a non-OK status during the readiness poll. +var errUnexpectedStatus = errors.New("unexpected dist node health status") + // TestDistRebalanceJoin verifies keys are migrated to a new node after join. func TestDistRebalanceJoin(t *testing.T) { + t.Parallel() + ctx := context.Background() // Initial cluster: 2 nodes. @@ -112,6 +119,8 @@ func TestDistRebalanceJoin(t *testing.T) { // TestDistRebalanceThrottle simulates saturation causing throttle metric increments. func TestDistRebalanceThrottle(t *testing.T) { + t.Parallel() + ctx := context.Background() addrA := allocatePort(t) @@ -174,7 +183,7 @@ func mustDistNode( ) *backend.DistMemory { t.Helper() - opts := []backend.DistMemoryOption{ + opts := []backend.DistMemoryOption{ //nolint:prealloc // literal helper-builder list, append-once with extra below backend.WithDistNode(id, addr), backend.WithDistSeeds(seeds), backend.WithDistHintReplayInterval(200 * time.Millisecond), @@ -190,7 +199,7 @@ func mustDistNode( t.Fatalf("new dist memory: %v", err) } - waitForDistNodeHealth(t, addr) + waitForDistNodeHealth(ctx, t, addr) bk, ok := bm.(*backend.DistMemory) if !ok { @@ -234,7 +243,7 @@ func ownedPrimaryCount(dm *backend.DistMemory, keys []string) int { return c } -func waitForDistNodeHealth(t *testing.T, addr string) { +func waitForDistNodeHealth(ctx context.Context, t *testing.T, addr string) { t.Helper() client := &http.Client{Timeout: 100 * time.Millisecond} @@ -244,7 +253,12 @@ func waitForDistNodeHealth(t *testing.T, addr string) { var lastErr error for time.Now().Before(deadline) { - resp, err := client.Get(healthURL) + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if reqErr != nil { + t.Fatalf("build health request: %v", reqErr) + } + + resp, err := client.Do(req) if err == nil { _, _ = io.Copy(io.Discard, resp.Body) _ = resp.Body.Close() @@ -253,7 +267,7 @@ func waitForDistNodeHealth(t *testing.T, addr string) { return } - lastErr = fmt.Errorf("unexpected status %d", resp.StatusCode) + lastErr = fmt.Errorf("%w: %d", errUnexpectedStatus, resp.StatusCode) } else { lastErr = err } diff --git a/tests/management_http_test.go b/tests/management_http_test.go index 2181cd0..bfd89cd 100644 --- a/tests/management_http_test.go +++ b/tests/management_http_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/goccy/go-json" - "github.com/longbridgeapp/assert" + "github.com/stretchr/testify/require" "github.com/hyp3rd/hypercache" "github.com/hyp3rd/hypercache/internal/constants" @@ -17,6 +17,8 @@ import ( // TestManagementHTTP_BasicEndpoints spins up the management HTTP server on an ephemeral port // and validates core endpoints. func TestManagementHTTP_BasicEndpoints(t *testing.T) { + t.Parallel() + cfg, err := hypercache.NewConfig[backend.InMemory](constants.InMemoryBackend) if err != nil { t.Fatalf("NewConfig: %v", err) @@ -29,9 +31,9 @@ func TestManagementHTTP_BasicEndpoints(t *testing.T) { ctx := context.Background() hc, err := hypercache.New(ctx, hypercache.GetDefaultManager(), cfg) - assert.Nil(t, err) + require.NoError(t, err) - defer hc.Stop(ctx) + t.Cleanup(func() { _ = hc.Stop(ctx) }) // Wait for the management HTTP listener to come up. The race detector // can push listener startup well past the original 30 ms; poll with a @@ -54,40 +56,40 @@ func TestManagementHTTP_BasicEndpoints(t *testing.T) { client := &http.Client{Timeout: 5 * time.Second} - // /health - resp, err := client.Get("http://" + addr + "/health") - if err != nil { - t.Fatalf("GET /health: %v", err) + get := func(path string) *http.Response { + t.Helper() + + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr+path, nil) + require.NoError(t, reqErr) + + resp, doErr := client.Do(req) + require.NoError(t, doErr) + + return resp } - assert.Equal(t, http.StatusOK, resp.StatusCode) + // /health + resp := get("/health") + require.Equal(t, http.StatusOK, resp.StatusCode) _ = resp.Body.Close() // /stats - resp, err = client.Get("http://" + addr + "/stats") - if err != nil { - t.Fatalf("GET /stats: %v", err) - } - - assert.Equal(t, http.StatusOK, resp.StatusCode) + resp = get("/stats") + require.Equal(t, http.StatusOK, resp.StatusCode) var statsBody map[string]any dec := json.NewDecoder(resp.Body) err = dec.Decode(&statsBody) - assert.NoError(t, err) + require.NoError(t, err) _ = resp.Body.Close() // /config - resp, err = client.Get("http://" + addr + "/config") - if err != nil { - t.Fatalf("GET /config: %v", err) - } - - assert.Equal(t, http.StatusOK, resp.StatusCode) + resp = get("/config") + require.Equal(t, http.StatusOK, resp.StatusCode) var cfgBody map[string]any @@ -95,7 +97,6 @@ func TestManagementHTTP_BasicEndpoints(t *testing.T) { _ = dec.Decode(&cfgBody) _ = resp.Body.Close() - assert.True(t, len(cfgBody) > 0) - - assert.True(t, cfgBody["evictionAlgorithm"] != nil) + require.NotEmpty(t, cfgBody) + require.NotNil(t, cfgBody["evictionAlgorithm"]) } diff --git a/tests/merkle_delete_tombstone_test.go b/tests/merkle_delete_tombstone_test.go index 1923c0f..784f001 100644 --- a/tests/merkle_delete_tombstone_test.go +++ b/tests/merkle_delete_tombstone_test.go @@ -11,6 +11,8 @@ import ( // TestMerkleDeleteTombstone ensures a deleted key does not resurrect via sync. func TestMerkleDeleteTombstone(t *testing.T) { + t.Parallel() + ctx := context.Background() transport := backend.NewInProcessTransport() diff --git a/tests/merkle_empty_tree_test.go b/tests/merkle_empty_tree_test.go index 796e11d..90f9ef5 100644 --- a/tests/merkle_empty_tree_test.go +++ b/tests/merkle_empty_tree_test.go @@ -9,6 +9,8 @@ import ( // TestMerkleEmptyTrees ensures diff between two empty trees is empty and SyncWith is no-op. func TestMerkleEmptyTrees(t *testing.T) { + t.Parallel() + ctx := context.Background() transport := backend.NewInProcessTransport() diff --git a/tests/merkle_no_diff_test.go b/tests/merkle_no_diff_test.go index fd03e96..bc3ca4e 100644 --- a/tests/merkle_no_diff_test.go +++ b/tests/merkle_no_diff_test.go @@ -11,6 +11,8 @@ import ( // TestMerkleNoDiff ensures SyncWith returns quickly when trees are identical. func TestMerkleNoDiff(t *testing.T) { + t.Parallel() + ctx := context.Background() transport := backend.NewInProcessTransport() diff --git a/tests/merkle_single_missing_key_test.go b/tests/merkle_single_missing_key_test.go index 6175430..2ddcf74 100644 --- a/tests/merkle_single_missing_key_test.go +++ b/tests/merkle_single_missing_key_test.go @@ -11,6 +11,8 @@ import ( // TestMerkleSingleMissingKey ensures a single remote-only key is detected and pulled. func TestMerkleSingleMissingKey(t *testing.T) { + t.Parallel() + ctx := context.Background() transport := backend.NewInProcessTransport() diff --git a/tests/merkle_sync_test.go b/tests/merkle_sync_test.go index 13ae5e4..7aec630 100644 --- a/tests/merkle_sync_test.go +++ b/tests/merkle_sync_test.go @@ -11,6 +11,8 @@ import ( // TestMerkleSyncConvergence ensures SyncWith pulls newer keys from remote. func TestMerkleSyncConvergence(t *testing.T) { + t.Parallel() + ctx := context.Background() transport := backend.NewInProcessTransport() @@ -89,4 +91,6 @@ func TestMerkleSyncConvergence(t *testing.T) { } } -func keyf(prefix string, i int) string { return prefix + ":" + string(rune('a'+i)) } +func keyf(prefix string, i int) string { + return prefix + ":" + string(rune('a'+i)) //nolint:gosec // test fixture, i bounded in callers +} From e3c291603b5bccc8d023a0b6b2e08a6db8834fea Mon Sep 17 00:00:00 2001 From: "F." Date: Sun, 3 May 2026 16:47:12 +0200 Subject: [PATCH 8/8] =?UTF-8?q?refactor(eviction):=20replace=20unsafe=20in?= =?UTF-8?q?t=E2=86=92uint32=20casts=20with=20safe=20converter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace two `//nolint:gosec` suppressed `uint32(...)` casts in `Sharded` with explicit calls to `converters.ToUint32` from `github.com/hyp3rd/sectools`. - `NewSharded`: compute shard mask via `converters.ToUint32(shardCount - 1)` and propagate any conversion error instead of silencing the lint warning. - `Evict`: convert `len(s.shards)` via `converters.ToUint32`, returning a zero-value on error rather than casting unchecked. - Add the `sectools/pkg/converters` import to `sharded.go`. - Register unsharded in `cspell.config.yaml` to keep spell-check clean. --- cspell.config.yaml | 1 + pkg/eviction/sharded.go | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/cspell.config.yaml b/cspell.config.yaml index e07fca2..5a7e1a8 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -159,6 +159,7 @@ words: - traefik - ugorji - unmarshals + - unsharded - upserted - upserts - varnamelen diff --git a/pkg/eviction/sharded.go b/pkg/eviction/sharded.go index 71a753e..3086a35 100644 --- a/pkg/eviction/sharded.go +++ b/pkg/eviction/sharded.go @@ -4,6 +4,7 @@ import ( "sync/atomic" "github.com/hyp3rd/ewrap" + "github.com/hyp3rd/sectools/pkg/converters" "github.com/hyp3rd/hypercache/internal/sentinel" cachev2 "github.com/hyp3rd/hypercache/pkg/cache/v2" @@ -52,9 +53,15 @@ func NewSharded(algorithmName string, totalCapacity, shardCount int) (*Sharded, shards[i] = shard } + // shardCount validated power-of-two above + mask, err := converters.ToUint32(shardCount - 1) + if err != nil { + return nil, ewrap.Wrapf(err, "shardCount %d", shardCount) + } + return &Sharded{ shards: shards, - mask: uint32(shardCount - 1), //nolint:gosec // shardCount validated power-of-two above + mask: mask, }, nil } @@ -82,7 +89,11 @@ func (s *Sharded) Delete(key string) { // always hitting the first non-empty one — relevant when one shard sees // disproportionately fewer Set calls than others. func (s *Sharded) Evict() (string, bool) { - n := uint32(len(s.shards)) //nolint:gosec // len(s.shards) bounded by shardCount param + // len(s.shards) bounded by shardCount param + n, err := converters.ToUint32(len(s.shards)) + if err != nil { + return "", false + } start := s.evictCursor.Add(1) - 1