From 57658ae2d97952b954729d839a561c7cd9122081 Mon Sep 17 00:00:00 2001 From: "F." Date: Fri, 28 Aug 2026 20:14:20 +0300 Subject: [PATCH 1/2] chore(deps): bump Go toolchain to 1.27.0 and update dependencies - Bump Go version to 1.27.0 across go.mod, Makefile, pre-commit hooks, and .project-settings.env - Bump golangci-lint to v2.13.1 - Update go.mod/go.sum dependencies (fiber/v3 to v3.5.0, go-redis/v9 to v9.22.0, OTEL trace/metric/sdk to v1.46.0, and other indirect modules) - Adjust error handling to use errors.AsType instead of errors.As in pkg/client/errors.go and tests/dist_http_limits_test.go - Update CHANGELOG.md --- .golangci.yaml | 3 +- .pre-commit/golangci-lint-hook | 2 +- .pre-commit/unit-test-hook | 2 +- .project-settings.env | 6 +- CHANGELOG.md | 377 ++++++++++++++++----------------- Makefile | 6 +- go.mod | 42 ++-- go.sum | 87 ++++---- pkg/client/errors.go | 4 +- tests/dist_http_limits_test.go | 3 +- 10 files changed, 261 insertions(+), 271 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 3113e3a..1b61579 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -42,7 +42,7 @@ run: # Define the Go version limit. # Mainly related to generics support since go1.18. # Default: use Go version from the go.mod file, fallback on the env var `GOVERSION`, fallback on 1.17 - go: "1.26.5" + go: "1.27.0" linters: # Enable specific linter @@ -52,6 +52,7 @@ linters: - gomodguard_v2 - wsl_v5 disable: + - exhaustruct_v5 - exhaustruct - depguard - gomodguard diff --git a/.pre-commit/golangci-lint-hook b/.pre-commit/golangci-lint-hook index 2083d43..4fef37c 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.12.2}" +GOLANGCI_LINT_VERSION="${GOLANGCI_LINT_VERSION:-v2.13.1}" # ####################################### # Install dependencies to run the pre-commit hook diff --git a/.pre-commit/unit-test-hook b/.pre-commit/unit-test-hook index d07c82a..f633902 100755 --- a/.pre-commit/unit-test-hook +++ b/.pre-commit/unit-test-hook @@ -21,7 +21,7 @@ hook() { local root_dir root_dir=$(git rev-parse --show-toplevel) - local toolchain_version="1.26.5" + local toolchain_version="1.27.0" if [[ -f "${root_dir}/.project-settings.env" ]]; then # shellcheck disable=SC1090 source "${root_dir}/.project-settings.env" diff --git a/.project-settings.env b/.project-settings.env index c264b90..3393065 100644 --- a/.project-settings.env +++ b/.project-settings.env @@ -1,5 +1,5 @@ -GOLANGCI_LINT_VERSION=v2.12.2 -BUF_VERSION=v1.70.0 -GO_VERSION=1.26.5 +GOLANGCI_LINT_VERSION=v2.13.1 +BUF_VERSION=v1.72.0 +GO_VERSION=1.27.0 GCI_PREFIX=github.com/hyp3rd/hypercache PROTO_ENABLED=false diff --git a/CHANGELOG.md b/CHANGELOG.md index b5899a5..7c19211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,106 +8,103 @@ All notable changes to HyperCache are recorded here. The format follows ### Added -- **Cluster-wide key browser (`GET /v1/cache/keys`).** New v1 client-API endpoint that fans out across - every alive peer, dedupes replicas, sorts, and returns a paged slice — designed for the operator-debug - workflow of "browse / refine a search" rather than as a primary data-access path. The `q` parameter - switches between two modes via a small classifier: - patterns containing any of `*`, `?`, `[` go through Go's `path.Match` (platform-agnostic glob — - `filepath.Match`'s OS-specific separator semantics are wrong for arbitrary string keys); - everything else is treated as a literal prefix via `strings.HasPrefix`. Two hard caps bound the - worst case: `max` (default 10000, ceiling 50000) for the full deduplicated result set held in memory, - and `limit` (default 100, ceiling 500) for the page size — `cursor` paging is offset-based against the - sorted set so successive pages are stable across requests. Per-peer fan-out failures are best-effort: - the failed peer ID lands in `partial_nodes` rather than failing the whole call, mirroring the - read-repair and hint-replay contracts elsewhere in the cluster. Returns 501 when the underlying - backend isn't `DistMemory` (this endpoint requires a cluster). The new method - [`(*DistMemory).ListKeys`](pkg/backend/dist_keys.go) drives the fan-out via `errgroup` with a - `listKeysAccumulator` merge struct keyed by a single mutex; the self-peer slice walks local shards - directly (no HTTP self-hop). The `DistTransport` interface grows a new method +- **Cluster-wide key browser (`GET /v1/cache/keys`).** New v1 client-API endpoint that fans out across every + alive peer, dedupes replicas, sorts, and returns a paged slice — designed for the operator-debug workflow of + "browse / refine a search" rather than as a primary data-access path. The `q` parameter switches between two + modes via a small classifier: patterns containing any of `*`, `?`, `[` go through Go's `path.Match` + (platform-agnostic glob — `filepath.Match`'s OS-specific separator semantics are wrong for arbitrary string + keys); everything else is treated as a literal prefix via `strings.HasPrefix`. Two hard caps bound the worst + case: `max` (default 10000, ceiling 50000) for the full deduplicated result set held in memory, and `limit` + (default 100, ceiling 500) for the page size — `cursor` paging is offset-based against the sorted set so + successive pages are stable across requests. Per-peer fan-out failures are best-effort: the failed peer ID + lands in `partial_nodes` rather than failing the whole call, mirroring the read-repair and hint-replay + contracts elsewhere in the cluster. Returns 501 when the underlying backend isn't `DistMemory` (this + endpoint requires a cluster). The new method [`(*DistMemory).ListKeys`](pkg/backend/dist_keys.go) drives the + fan-out via `errgroup` with a `listKeysAccumulator` merge struct keyed by a single mutex; the self-peer + slice walks local shards directly (no HTTP self-hop). The `DistTransport` interface grows a new method `ListKeys(ctx, nodeID, pattern)` with implementations in `InProcessTransport` (direct shard scan), - `DistHTTPTransport` (extends the existing `/internal/keys` path with an optional `q` query param — - backward compatible; cursor semantics unchanged), and `chaosTransport` (pass-through with the same - drop/latency injection hooks as the other verbs). Unit tests in - [`pkg/backend/dist_keys_test.go`](pkg/backend/dist_keys_test.go) pin the - prefix-vs-glob classifier across twelve table cases and the malformed-glob → `path.ErrBadPattern` - surface; HTTP smoke tests in [`cmd/hypercache-server/handlers_test.go`](cmd/hypercache-server/handlers_test.go) - drive seed → paged walk → assert union and no cross-page duplicates, plus 400 surfaces for invalid - cursor and malformed glob; five integration tests in - [`tests/hypercache_distmemory_listkeys_test.go`](tests/hypercache_distmemory_listkeys_test.go) - cover cluster-wide dedup at RF=3 across 5 nodes (50 unique seeds → 50 keys, not 150 = 50 × RF=3), - prefix vs glob filters, and `max`-triggered truncation. Route registration order matters in Fiber's - trie router: `/v1/cache/keys` must come before `/v1/cache/:key`, otherwise the parameterized handler - shadows it with `key="keys"`. OpenAPI spec entry (`ListKeysResponse` schema + operation) added to - [`cmd/hypercache-server/openapi.yaml`](cmd/hypercache-server/openapi.yaml); the drift-detector test - in [`cmd/hypercache-server/openapi_test.go`](cmd/hypercache-server/openapi_test.go) catches future - spec / route mismatches. -- **Async read-repair batching (Phase 4) + unconditional `ForwardSet`-only repair.** Two composing changes - in the same PR that together cut the wire-call cost of read-repair under quorum reads. (1) The defensive - `ForwardGet` probe in `repairRemoteReplica` is gone — every repair is now exactly one `ForwardSet`, - because the receiver's `applySet` already version-compares and noops downgrades, so the probe was pure - duplication. ~50% wire-call reduction per repair regardless of batching. (2) New opt-in + `DistHTTPTransport` (extends the existing `/internal/keys` path with an optional `q` query param — backward + compatible; cursor semantics unchanged), and `chaosTransport` (pass-through with the same drop/latency + injection hooks as the other verbs). Unit tests in + [`pkg/backend/dist_keys_test.go`](pkg/backend/dist_keys_test.go) pin the prefix-vs-glob classifier across + twelve table cases and the malformed-glob → `path.ErrBadPattern` surface; HTTP smoke tests in + [`cmd/hypercache-server/handlers_test.go`](cmd/hypercache-server/handlers_test.go) drive seed → paged walk → + assert union and no cross-page duplicates, plus 400 surfaces for invalid cursor and malformed glob; five + integration tests in + [`tests/hypercache_distmemory_listkeys_test.go`](tests/hypercache_distmemory_listkeys_test.go) cover + cluster-wide dedup at RF=3 across 5 nodes (50 unique seeds → 50 keys, not 150 = 50 × RF=3), prefix vs glob + filters, and `max`-triggered truncation. Route registration order matters in Fiber's trie router: + `/v1/cache/keys` must come before `/v1/cache/:key`, otherwise the parameterized handler shadows it with + `key="keys"`. OpenAPI spec entry (`ListKeysResponse` schema + operation) added to + [`cmd/hypercache-server/openapi.yaml`](cmd/hypercache-server/openapi.yaml); the drift-detector test in + [`cmd/hypercache-server/openapi_test.go`](cmd/hypercache-server/openapi_test.go) catches future spec / route + mismatches. +- **Async read-repair batching (Phase 4) + unconditional `ForwardSet`-only repair.** Two composing changes in + the same PR that together cut the wire-call cost of read-repair under quorum reads. (1) The defensive + `ForwardGet` probe in `repairRemoteReplica` is gone — every repair is now exactly one `ForwardSet`, because + the receiver's `applySet` already version-compares and noops downgrades, so the probe was pure duplication. + ~50% wire-call reduction per repair regardless of batching. (2) New opt-in [`backend.WithDistReadRepairBatch(interval, maxBatchSize)`](pkg/backend/dist_memory.go) option queues repairs by destination peer + key (last-write-wins by `(version, origin)`) and dispatches per-peer batches on the interval or when a peer's pending count hits `maxBatchSize`. Concurrent reads of the same hot key - produce ONE repair through the queue, not N — the coalescer collapses duplicate `(peer, key)` entries - and bumps the new `dist.read_repair.coalesced` counter per collapsed enqueue. Disabled by default + produce ONE repair through the queue, not N — the coalescer collapses duplicate `(peer, key)` entries and + bumps the new `dist.read_repair.coalesced` counter per collapsed enqueue. Disabled by default (`interval == 0` = current synchronous behavior preserved, so `TestDistMemoryReadRepair` and `TestDistMemoryRemoveReplication` pass byte-identical). Clean shutdown drains the queue inside `Stop()`; - crash exit loses queued repairs by design, with merkle anti-entropy as the convergence safety net. - New [`pkg/backend/dist_read_repair.go`](pkg/backend/dist_read_repair.go) hosts the `repairQueue` type - with errgroup-driven per-peer parallel `ForwardSet` dispatch. Eight unit tests in - [`pkg/backend/dist_read_repair_test.go`](pkg/backend/dist_read_repair_test.go) cover the coalesce rule - (same `(peer, key)` keeps the higher version, distinct peers stay independent), the size-threshold - inline flush, the nil-transport noop path, the `Stop()` drain semantics, the `(version, origin)` - tie-break rule, and concurrent-enqueue race-safety. Three integration tests in + crash exit loses queued repairs by design, with merkle anti-entropy as the convergence safety net. New + [`pkg/backend/dist_read_repair.go`](pkg/backend/dist_read_repair.go) hosts the `repairQueue` type with + errgroup-driven per-peer parallel `ForwardSet` dispatch. Eight unit tests in + [`pkg/backend/dist_read_repair_test.go`](pkg/backend/dist_read_repair_test.go) cover the coalesce rule (same + `(peer, key)` keeps the higher version, distinct peers stay independent), the size-threshold inline flush, + the nil-transport noop path, the `Stop()` drain semantics, the `(version, origin)` tie-break rule, and + concurrent-enqueue race-safety. Three integration tests in [`tests/hypercache_distmemory_readrepair_batch_test.go`](tests/hypercache_distmemory_readrepair_batch_test.go) - drive the end-to-end shape — a 3-node RF=3 ConsistencyQuorum cluster, one node's local copy dropped, - N concurrent Gets from a third node — and assert the batched flush heals the dropped node, parallel - reads coalesce to ≤2 dispatches (one per remote owner) regardless of N, and `Stop()` drains queued - repairs before returning. Two new OTel metrics: - `dist.read_repair.batched` (per actual `ForwardSet` dispatched by the queue's flusher) and - `dist.read_repair.coalesced` (per duplicate-enqueue collapsed). New "Tuning — read-repair batching" - section in [`docs/operations.md`](docs/operations.md) covers the option shape, the divergence-window - trade-off, the two metrics, and when to enable it (high read-amplification with stable hot keys). + drive the end-to-end shape — a 3-node RF=3 ConsistencyQuorum cluster, one node's local copy dropped, N + concurrent Gets from a third node — and assert the batched flush heals the dropped node, parallel reads + coalesce to ≤2 dispatches (one per remote owner) regardless of N, and `Stop()` drains queued repairs before + returning. Two new OTel metrics: `dist.read_repair.batched` (per actual `ForwardSet` dispatched by the + queue's flusher) and `dist.read_repair.coalesced` (per duplicate-enqueue collapsed). New "Tuning — + read-repair batching" section in [`docs/operations.md`](docs/operations.md) covers the option shape, the + divergence-window trade-off, the two metrics, and when to enable it (high read-amplification with stable hot + keys). - **Token-refresh visibility for the OIDC source.** Closes RFC 0003 open question 6: the `WithOIDCClientCredentials` source now wraps its `oauth2.TokenSource` with a logger that emits one `"oidc token rotated"` Info line per real rotation (expiry change), staying silent on cached returns. Operators debugging "why are my requests suddenly 401?" now see token age in the structured log alongside - the other lifecycle events. The wrapper holds the `*Client` by reference rather than capturing - `c.logger` at construction time, so `WithLogger` applied AFTER `WithOIDCClientCredentials` still reaches - the rotation log surface. Three unit tests in [`pkg/client/oidc_logging_test.go`](pkg/client/oidc_logging_test.go) - cover the rotation-logs case, the cached-returns-stay-silent case, and the nil-Client defensive path. + the other lifecycle events. The wrapper holds the `*Client` by reference rather than capturing `c.logger` at + construction time, so `WithLogger` applied AFTER `WithOIDCClientCredentials` still reaches the rotation log + surface. Three unit tests in [`pkg/client/oidc_logging_test.go`](pkg/client/oidc_logging_test.go) cover the + rotation-logs case, the cached-returns-stay-silent case, and the nil-Client defensive path. - **`GET /v1/me/can` capability probe + `Client.Can(ctx, capability)` SDK method.** Closes RFC 0003 open - question 5: callers can now check "do I have write?" without the speculative-write pattern (try the - action, catch the 403). The server endpoint validates against a closed set of capability strings - (`cache.read` / `cache.write` / `cache.admin`); unknown values return 400 BAD_REQUEST so typos surface as - client errors rather than silently degrading to allowed=false. The SDK method mirrors this: - `(true, nil)` / `(false, nil)` for the allow/deny answers; `errors.Is(err, ErrBadRequest)` for the - spelling-mistake path. `Identity.HasCapability` added to [`pkg/httpauth/policy.go`](pkg/httpauth/policy.go) - as the single authoritative check used by both the server handler and the SDK. Three handler tests in - [`cmd/hypercache-server/me_test.go`](cmd/hypercache-server/me_test.go) cover allowed/denied/unknown; - three SDK tests in [`pkg/client/client_test.go`](pkg/client/client_test.go) cover the parallel surface. - OpenAPI spec ([`cmd/hypercache-server/openapi.yaml`](cmd/hypercache-server/openapi.yaml)) gains the - `/v1/me/can` operation + `CanResponse` schema. New "Probing a single capability with `Can`" and - "Token-refresh visibility" sections in [`docs/client-sdk.md`](docs/client-sdk.md). + question 5: callers can now check "do I have write?" without the speculative-write pattern (try the action, + catch the 403). The server endpoint validates against a closed set of capability strings (`cache.read` / + `cache.write` / `cache.admin`); unknown values return 400 BAD_REQUEST so typos surface as client errors + rather than silently degrading to allowed=false. The SDK method mirrors this: `(true, nil)` / `(false, nil)` + for the allow/deny answers; `errors.Is(err, ErrBadRequest)` for the spelling-mistake path. + `Identity.HasCapability` added to [`pkg/httpauth/policy.go`](pkg/httpauth/policy.go) as the single + authoritative check used by both the server handler and the SDK. Three handler tests in + [`cmd/hypercache-server/me_test.go`](cmd/hypercache-server/me_test.go) cover allowed/denied/unknown; three + SDK tests in [`pkg/client/client_test.go`](pkg/client/client_test.go) cover the parallel surface. OpenAPI + spec ([`cmd/hypercache-server/openapi.yaml`](cmd/hypercache-server/openapi.yaml)) gains the `/v1/me/can` + operation + `CanResponse` schema. New "Probing a single capability with `Can`" and "Token-refresh + visibility" sections in [`docs/client-sdk.md`](docs/client-sdk.md). - **Chaos hooks for resilience testing (Phase 7).** New [`backend.WithDistChaos(*Chaos)`](pkg/backend/dist_chaos.go) option transparently wraps the dist transport with configurable fault injection — drop rate and latency injection, both with per-call probability rolls - off a crypto-seeded math/rand source. The wrapper is automatic for both the explicit - `WithDistTransport` path and the auto-wired HTTP transport, so chaos covers every dist call uniformly. - Disabled by default (zero overhead) and opt-in by design — the doc comment is explicit that this is a - test-only surface with no production safety net. Atomic mutators (`SetDropRate`, `SetLatency`) let tests - enable chaos mid-run, drive the cluster, then heal — exactly the shape the rebalance flake we caught in - May 2026 needed to be surfaced deterministically. Two new OTel metrics: - `dist.chaos.drops` (calls dropped) and `dist.chaos.latencies` (calls with latency injected). Eight unit - tests in [`pkg/backend/dist_chaos_test.go`](pkg/backend/dist_chaos_test.go) cover every branch - (DropRate=1 always drops, DropRate=0 never drops, latency injection fires + delays the call, nil-Chaos - passes through unchanged, the disabled-but-installed wrapper is a pass-through, concurrent calls are - race-free under -race, boundary clamping for out-of-range probabilities, nil-receiver safety on the - Metrics() snapshot path). Two integration tests in - [`tests/integration/dist_chaos_test.go`](tests/integration/dist_chaos_test.go) drive the canonical - resilience scenario — 80% drops force the hint queue to absorb replica fan-out failures; disabling chaos - lets the replay loop drain the queue. New "Chaos hooks (resilience testing)" section in + off a crypto-seeded math/rand source. The wrapper is automatic for both the explicit `WithDistTransport` + path and the auto-wired HTTP transport, so chaos covers every dist call uniformly. Disabled by default (zero + overhead) and opt-in by design — the doc comment is explicit that this is a test-only surface with no + production safety net. Atomic mutators (`SetDropRate`, `SetLatency`) let tests enable chaos mid-run, drive + the cluster, then heal — exactly the shape the rebalance flake we caught in May 2026 needed to be surfaced + deterministically. Two new OTel metrics: `dist.chaos.drops` (calls dropped) and `dist.chaos.latencies` + (calls with latency injected). Eight unit tests in + [`pkg/backend/dist_chaos_test.go`](pkg/backend/dist_chaos_test.go) cover every branch (DropRate=1 always + drops, DropRate=0 never drops, latency injection fires + delays the call, nil-Chaos passes through + unchanged, the disabled-but-installed wrapper is a pass-through, concurrent calls are race-free under -race, + boundary clamping for out-of-range probabilities, nil-receiver safety on the Metrics() snapshot path). Two + integration tests in [`tests/integration/dist_chaos_test.go`](tests/integration/dist_chaos_test.go) drive + the canonical resilience scenario — 80% drops force the hint queue to absorb replica fan-out failures; + disabling chaos lets the replay loop drain the queue. New "Chaos hooks (resilience testing)" section in [`docs/operations.md`](docs/operations.md) with the usage shape and the "what this catches that CI flake hunting won't" rationale. - **Batch operations on the client SDK.** `BatchSet`, `BatchGet`, `BatchDelete` close the v1 SDK gap PR3's @@ -319,119 +316,115 @@ All notable changes to HyperCache are recorded here. The format follows ### Fixed -- **applySet now clones the key string before storing it as the shard's map key.** Under HTTP traffic - (Fiber + the v1 cache API), path parameters returned by `c.Params("key")` are backed by a pooled - request buffer that the framework reuses for the next request. The original `applySet` stored the - caller's string directly as the `ConcurrentMap` key; when the next request landed, the buffer's - bytes mutated, and so did every map key (and `Item.Key` field) we'd previously stored. The - immediate symptom: the same logical key drifted across multiple shards (`first-24` showing up in - shards 2, 3, 4, and twice in shard 6), and phantom keys like `first-479` materialized in the - iteration (a "first-4" buffer overlaid with "79" from the next URL). The rebalance loop, scanning - `sh.items.All()`, kept re-flagging these phantoms — `RebalancedPrimary` climbed at ~60/s on a - 5-node cluster after a single 100-key write batch, even though MembershipVersion, hint queues, - merkle counters, and the `WriteApplyRefused` guard were all quiet. The fix is one - `strings.Clone(item.Key)` call in [`applySet`](pkg/backend/dist_memory.go) before recording the - originalPrimary and storing: the cloned key has its own backing array, fully detached from the - caller's pooled buffer. The `Item.Key` field on the stored clone gets the same stable value so - any downstream code observes a coherent shard entry. Post-fix the cluster's rebalance counters - stay at exactly zero in steady state across all 5 nodes. +- **applySet now clones the key string before storing it as the shard's map key.** Under HTTP traffic (Fiber + + the v1 cache API), path parameters returned by `c.Params("key")` are backed by a pooled request buffer that + the framework reuses for the next request. The original `applySet` stored the caller's string directly as + the `ConcurrentMap` key; when the next request landed, the buffer's bytes mutated, and so did every map key + (and `Item.Key` field) we'd previously stored. The immediate symptom: the same logical key drifted across + multiple shards (`first-24` showing up in shards 2, 3, 4, and twice in shard 6), and phantom keys like + `first-479` materialized in the iteration (a "first-4" buffer overlaid with "79" from the next URL). The + rebalance loop, scanning `sh.items.All()`, kept re-flagging these phantoms — `RebalancedPrimary` climbed at + ~60/s on a 5-node cluster after a single 100-key write batch, even though MembershipVersion, hint queues, + merkle counters, and the `WriteApplyRefused` guard were all quiet. The fix is one `strings.Clone(item.Key)` + call in [`applySet`](pkg/backend/dist_memory.go) before recording the originalPrimary and storing: the + cloned key has its own backing array, fully detached from the caller's pooled buffer. The `Item.Key` field + on the stored clone gets the same stable value so any downstream code observes a coherent shard entry. + Post-fix the cluster's rebalance counters stay at exactly zero in steady state across all 5 nodes. - **Receiver-side ownership guard breaks the divergent-ring-view rebalance loop.** After the `migrateIfNeeded`-side fix (one migration per stuck key, then release) shipped, operators on a 5-node - cluster running [`scripts/tests/30-test-cluster-writes.sh`](scripts/tests/30-test-cluster-writes.sh) - still saw `RebalancedPrimary` climb at ~60/s post-script with no membership, hint, or merkle activity. - Root cause: when the migration target's ring view still treated the original source as a replica, the - target's `applySet` fan-out re-planted the key on the source. The source released it (per the earlier - fix), then received it back on the next gossip tick, then migrated again — perpetual cycle even though - no state was actually transitioning. New [`applyForwardedSet`](pkg/backend/dist_memory.go) is the entry - point used by the transport-receiver paths (`InProcessTransport.ForwardSet` and the HTTP - `/internal/set` handler) and applies an ownership guard: if the receiver's ring view says it isn't an - owner of the key, the write is silently dropped. The sender's transport call still returns nil (no - behavioral break — best-effort semantics already governed the hot path), but the receiver's shard - stays clean. Merkle anti-entropy is the convergence safety net for any write refused here. The guard - is deliberately NOT in `applySet` itself: legitimate internal callers (setImpl primary path, - `migrateIfNeeded` forwarder, merkle pull, read-repair) have either already verified ownership or - explicitly want to plant regardless — moving the guard would have broken `TestHTTPFetchMerkle`. New - `dist.write.apply_refused` counter exposes how often the guard fires (zero on healthy views; - non-zero indicates divergence operators may want to investigate). New test + cluster running [`scripts/tests/30-test-cluster-writes.sh`](scripts/tests/30-test-cluster-writes.sh) still + saw `RebalancedPrimary` climb at ~60/s post-script with no membership, hint, or merkle activity. Root cause: + when the migration target's ring view still treated the original source as a replica, the target's + `applySet` fan-out re-planted the key on the source. The source released it (per the earlier fix), then + received it back on the next gossip tick, then migrated again — perpetual cycle even though no state was + actually transitioning. New [`applyForwardedSet`](pkg/backend/dist_memory.go) is the entry point used by the + transport-receiver paths (`InProcessTransport.ForwardSet` and the HTTP `/internal/set` handler) and applies + an ownership guard: if the receiver's ring view says it isn't an owner of the key, the write is silently + dropped. The sender's transport call still returns nil (no behavioral break — best-effort semantics already + governed the hot path), but the receiver's shard stays clean. Merkle anti-entropy is the convergence safety + net for any write refused here. The guard is deliberately NOT in `applySet` itself: legitimate internal + callers (setImpl primary path, `migrateIfNeeded` forwarder, merkle pull, read-repair) have either already + verified ownership or explicitly want to plant regardless — moving the guard would have broken + `TestHTTPFetchMerkle`. New `dist.write.apply_refused` counter exposes how often the guard fires (zero on + healthy views; non-zero indicates divergence operators may want to investigate). New test [`TestDistRebalance_ApplyOwnershipGuardRefusesForeignWrites`](tests/hypercache_distmemory_rebalance_steady_test.go) - drives a direct `ForwardSet` to a non-owner and asserts the shard stays clean and the refused-counter - ticks up. + drives a direct `ForwardSet` to a non-owner and asserts the shard stays clean and the refused-counter ticks + up. - **Rebalance counters no longer climb in a steady-state cluster.** When a key was no longer owned by the - current node — because the ring had shifted away from it (typical after a node joins or a singleton - cluster gains peers) — [`migrateIfNeeded`](pkg/backend/dist_memory.go) forwarded the value to the new - primary but only scheduled the LOCAL copy for deletion when `WithDistRemovalGrace > 0`. The default - removal-grace setting is zero, which meant the local item was never released; on every subsequent - rebalance tick `shouldRebalance` re-flagged the same key via its `!ownsKeyInternal` branch, and - `migrateIfNeeded` re-emitted the migration. Operators saw `RebalancedKeys` and `RebalancedPrimary` - climb at the scan-tick rate forever — e.g. 5,326 keys / 5,102 primary migrations on a 5-node cluster - with ~14 stuck keys and a 100ms ticker, even though no membership had actually changed. Migration now - releases the local copy immediately when `removalGracePeriod == 0` (and continues to schedule a - deferred delete via `shedRemovedKeys` when a grace period is configured), so each stuck key produces - exactly one migration and the loop quiesces. Two new integration tests in + current node — because the ring had shifted away from it (typical after a node joins or a singleton cluster + gains peers) — [`migrateIfNeeded`](pkg/backend/dist_memory.go) forwarded the value to the new primary but + only scheduled the LOCAL copy for deletion when `WithDistRemovalGrace > 0`. The default removal-grace + setting is zero, which meant the local item was never released; on every subsequent rebalance tick + `shouldRebalance` re-flagged the same key via its `!ownsKeyInternal` branch, and `migrateIfNeeded` + re-emitted the migration. Operators saw `RebalancedKeys` and `RebalancedPrimary` climb at the scan-tick rate + forever — e.g. 5,326 keys / 5,102 primary migrations on a 5-node cluster with ~14 stuck keys and a 100ms + ticker, even though no membership had actually changed. Migration now releases the local copy immediately + when `removalGracePeriod == 0` (and continues to schedule a deferred delete via `shedRemovedKeys` when a + grace period is configured), so each stuck key produces exactly one migration and the loop quiesces. Two new + integration tests in [`tests/hypercache_distmemory_rebalance_steady_test.go`](tests/hypercache_distmemory_rebalance_steady_test.go) pin both contracts: `TestDistRebalance_IdleClusterIsSilent` asserts a 5-node RF=3 cluster with no out-of-place keys produces zero counter bumps across many ticks, and - `TestDistRebalance_LostOwnershipDrainsOnce` plants one stuck key per node via `DebugInject` and asserts - the counters reach exactly one bump per stuck key and never advance after that. -- **Incarnation and MembershipVersion no longer churn on every heartbeat.** SWIM-style incarnation - numbers and the membership version vector were both inflating roughly in lock-step with elapsed-probes — - a 5-node cluster running for a few hours showed incarnations near 2,378 per peer and a MembershipVersion - past 4,800, even though no nodes had actually changed state. [`Membership.Mark`](internal/cluster/membership.go) - was unconditionally incrementing incarnation, advancing the version counter, AND firing observers on - every call; the heartbeat-success path in `evaluateLiveness` calls `Mark(peer, NodeAlive)` once per probe - per peer. Three downstream effects: (i) operators couldn't read incarnation as a state-change signal, - (ii) gossip-merge fanned out spurious "version went up" deltas, (iii) SSE consumers received constant - no-op `members` events. Mark now treats same-state as a full no-op — LastSeen still refreshes (the - suspect-after timeout machinery needs that), but incarnation, version, and observers all stay quiet. - Genuine state transitions (Alive↔Suspect) still bump all three, so the "higher incarnation wins" gossip - merge continues to propagate real changes. New [`Membership.Refute`](internal/cluster/membership.go) is - the explicit SWIM self-refute primitive: it always bumps incarnation and sets state to NodeAlive, even - when the local view is already Alive — the one path that legitimately needs to publish a - higher-incarnation refutation packet regardless of local-view state. `refuteIfSuspected` in + `TestDistRebalance_LostOwnershipDrainsOnce` plants one stuck key per node via `DebugInject` and asserts the + counters reach exactly one bump per stuck key and never advance after that. +- **Incarnation and MembershipVersion no longer churn on every heartbeat.** SWIM-style incarnation numbers and + the membership version vector were both inflating roughly in lock-step with elapsed-probes — a 5-node + cluster running for a few hours showed incarnations near 2,378 per peer and a MembershipVersion past 4,800, + even though no nodes had actually changed state. [`Membership.Mark`](internal/cluster/membership.go) was + unconditionally incrementing incarnation, advancing the version counter, AND firing observers on every call; + the heartbeat-success path in `evaluateLiveness` calls `Mark(peer, NodeAlive)` once per probe per peer. + Three downstream effects: (i) operators couldn't read incarnation as a state-change signal, (ii) + gossip-merge fanned out spurious "version went up" deltas, (iii) SSE consumers received constant no-op + `members` events. Mark now treats same-state as a full no-op — LastSeen still refreshes (the suspect-after + timeout machinery needs that), but incarnation, version, and observers all stay quiet. Genuine state + transitions (Alive↔Suspect) still bump all three, so the "higher incarnation wins" gossip merge continues to + propagate real changes. New [`Membership.Refute`](internal/cluster/membership.go) is the explicit SWIM + self-refute primitive: it always bumps incarnation and sets state to NodeAlive, even when the local view is + already Alive — the one path that legitimately needs to publish a higher-incarnation refutation packet + regardless of local-view state. `refuteIfSuspected` in [`pkg/backend/dist_memory.go`](pkg/backend/dist_memory.go) switched from `Mark(localID, NodeAlive)` to `Refute(localID)` so the divergent semantic is obvious at the call site. Five new unit tests in [`internal/cluster/membership_test.go`](internal/cluster/membership_test.go) pin: no-incarnation-bump on - same-state Mark, no-version-bump and no-observer-fire on same-state Mark, bump-on-transition, refute - always bumps, and the ghost-node guard. The existing `TestDistSWIM_SelfRefute` integration test - continues to pass byte-identical. -- **Remove path no longer silently succeeds when the primary is unreachable.** - Symmetric audit-fix to the Set-forward change: [`removeImpl`](pkg/backend/dist_memory.go) used to - swallow the `ForwardRemove` error with `_ = transport.ForwardRemove(...)` and return `nil`, so a - Remove against a downed primary "succeeded" while the stale value lingered on every owner. Promotion - is now extracted into `forwardOrPromoteRemove`, mirroring `handleForwardPrimary`'s contract: on any - non-nil error, if the local node is a replica owner, apply the remove locally + fan out to peer - replicas via the existing `applyRemove(replicate=true)` path; otherwise return the error. The - promotion path bumps the shared `dist.write.forward_promotion` counter, so operators see Set + Remove - promotions on the same observable instrument. The dead primary catches up via merkle anti-entropy on - restart — the same convergence mechanism that already handles replica-side tombstones in - `replicateRemoveWithSpan`. New test [`TestDistRemove_PromotesOnGenericForwardError`](tests/hypercache_distmemory_audit_fixes_test.go) - drives chaos at `DropRate=1.0` and asserts the Remove returns `nil` (promotion succeeded), the local - copy is cleared, and the promotion counter bumped. + same-state Mark, no-version-bump and no-observer-fire on same-state Mark, bump-on-transition, refute always + bumps, and the ghost-node guard. The existing `TestDistSWIM_SelfRefute` integration test continues to pass + byte-identical. +- **Remove path no longer silently succeeds when the primary is unreachable.** Symmetric audit-fix to the + Set-forward change: [`removeImpl`](pkg/backend/dist_memory.go) used to swallow the `ForwardRemove` error + with `_ = transport.ForwardRemove(...)` and return `nil`, so a Remove against a downed primary "succeeded" + while the stale value lingered on every owner. Promotion is now extracted into `forwardOrPromoteRemove`, + mirroring `handleForwardPrimary`'s contract: on any non-nil error, if the local node is a replica owner, + apply the remove locally + fan out to peer replicas via the existing `applyRemove(replicate=true)` path; + otherwise return the error. The promotion path bumps the shared `dist.write.forward_promotion` counter, so + operators see Set + Remove promotions on the same observable instrument. The dead primary catches up via + merkle anti-entropy on restart — the same convergence mechanism that already handles replica-side tombstones + in `replicateRemoveWithSpan`. New test + [`TestDistRemove_PromotesOnGenericForwardError`](tests/hypercache_distmemory_audit_fixes_test.go) drives + chaos at `DropRate=1.0` and asserts the Remove returns `nil` (promotion succeeded), the local copy is + cleared, and the promotion counter bumped. - **Hint replay retains the queue on any transient transport error.** [`processHint`](pkg/backend/dist_memory.go) used to drop the hint unless the in-process - `errors.Is(err, sentinel.ErrBackendNotFound)` matched. Production HTTP/gRPC transports surface - `net.OpError` / `io.EOF` / `context.DeadlineExceeded` for a peer that's mid-restart or briefly - unreachable — none of which matched the gate, so the hint was abandoned on its very first replay - attempt instead of being retained through the outage. The exact failure mode behind the - `recovery on :8083 timed out after 60s: pre=50/50, during=43/50` symptom in the cluster-resilience - workflow: even with the Set-forward promotion in place, the hint queue lost the writes to the dead - primary before it came back. Now any non-nil error retains the hint; the configured `WithDistHintTTL` - bounds total retry time, so a permanently-broken target still drains. The deprecated `HintedDropped` - / `MigrationHintDropped` OTel counters remain registered for stability but now only bump on - queue-capacity overflow, not replay errors. New test - [`TestDistHintReplay_RetainsOnGenericReplayError`](tests/hypercache_distmemory_audit_fixes_test.go) - forces a 150ms window of failed replays under chaos, heals chaos, and asserts the hint still replays - onto the recovered peer. + `errors.Is(err, sentinel.ErrBackendNotFound)` matched. Production HTTP/gRPC transports surface `net.OpError` + / `io.EOF` / `context.DeadlineExceeded` for a peer that's mid-restart or briefly unreachable — none of which + matched the gate, so the hint was abandoned on its very first replay attempt instead of being retained + through the outage. The exact failure mode behind the + `recovery on :8083 timed out after 60s: pre=50/50, during=43/50` symptom in the cluster-resilience workflow: + even with the Set-forward promotion in place, the hint queue lost the writes to the dead primary before it + came back. Now any non-nil error retains the hint; the configured `WithDistHintTTL` bounds total retry time, + so a permanently-broken target still drains. The deprecated `HintedDropped` / `MigrationHintDropped` OTel + counters remain registered for stability but now only bump on queue-capacity overflow, not replay errors. + New test [`TestDistHintReplay_RetainsOnGenericReplayError`](tests/hypercache_distmemory_audit_fixes_test.go) + forces a 150ms window of failed replays under chaos, heals chaos, and asserts the hint still replays onto + the recovered peer. - **Set-forward promotion no longer requires the in-process `ErrBackendNotFound` sentinel, and the dead primary now converges via the hint queue (not just the next merkle tick).** - [`handleForwardPrimary`](pkg/backend/dist_memory.go) used to gate "primary unreachable → promote to - replica" on `errors.Is(errFwd, sentinel.ErrBackendNotFound)`, the error the in-process transport returns - for an unregistered peer. HTTP/gRPC transports against a stopped container surface - `net.OpError` / `io.EOF` / `context.DeadlineExceeded` instead — none of which matched the condition. - Result: when a cluster node was killed (e.g. `docker stop` in + [`handleForwardPrimary`](pkg/backend/dist_memory.go) used to gate "primary unreachable → promote to replica" + on `errors.Is(errFwd, sentinel.ErrBackendNotFound)`, the error the in-process transport returns for an + unregistered peer. HTTP/gRPC transports against a stopped container surface `net.OpError` / `io.EOF` / + `context.DeadlineExceeded` instead — none of which matched the condition. Result: when a cluster node was + killed (e.g. `docker stop` in [`scripts/tests/20-test-cluster-resilience.sh`](scripts/tests/20-test-cluster-resilience.sh)), writes for - keys whose primary was the dead node failed immediately at the forwarding hop, no hint was queued, and - the data never landed anywhere — the same 7 of 50 "during-*" writes failed reproducibly in CI's cluster + keys whose primary was the dead node failed immediately at the forwarding hop, no hint was queued, and the + data never landed anywhere — the same 7 of 50 "during-\*" writes failed reproducibly in CI's cluster workflow. Promotion now triggers on **any** non-nil forward error when the local node is in `owners[1:]`, matching the in-process and production transport behavior under the same resilience contract. Spurious promotion on a transient blip is benign — `applySet` version-compares on the receiver, and merkle @@ -439,14 +432,14 @@ All notable changes to HyperCache are recorded here. The format follows last-write-wins rule. Defense-in-depth follow-up: when promotion fires, `setImpl` now widens the replica fan-out from `owners[1:]` to the full `owners` list, so `replicateTo`'s existing best-effort hint queueing catches the failed forward to the dead primary. Its post-restart convergence window is bounded by - hint-replay (`WithDistHintReplayInterval`, ~200ms in the default cluster config) rather than waiting for - the next merkle tick. New OTel counter `dist.write.forward_promotion` exposes how often promotion fired — - a flapping primary surfaces as a steady rise here, well before any read- or write-side error spikes. - Test [`TestDistSet_PromotesOnGenericForwardError`](tests/hypercache_distmemory_forward_primary_promotion_test.go) - uses the chaos hooks at `DropRate=1.0` to deterministically force a generic forward error, asserts the - Set succeeds via promotion, that `HintedQueued` bumps, and — after chaos clears — that the original - primary receives the write through the natural hint-replay loop. The existing `TestDistFailureRecovery` - continues to pass byte-identical (the change widens the promotion gate, doesn't narrow it). + hint-replay (`WithDistHintReplayInterval`, ~200ms in the default cluster config) rather than waiting for the + next merkle tick. New OTel counter `dist.write.forward_promotion` exposes how often promotion fired — a + flapping primary surfaces as a steady rise here, well before any read- or write-side error spikes. Test + [`TestDistSet_PromotesOnGenericForwardError`](tests/hypercache_distmemory_forward_primary_promotion_test.go) + uses the chaos hooks at `DropRate=1.0` to deterministically force a generic forward error, asserts the Set + succeeds via promotion, that `HintedQueued` bumps, and — after chaos clears — that the original primary + receives the write through the natural hint-replay loop. The existing `TestDistFailureRecovery` continues to + pass byte-identical (the change widens the promotion gate, doesn't narrow it). - **`TestDistRebalanceReplicaDiffThrottle` no longer flakes under `make test-race`.** The test's 900ms hard sleep wasn't enough wall-clock budget for the rebalancer's 80ms-tick loop to actually fire 11 ticks under `-race` + `-shuffle=on`'s scheduler pressure. Replaced the sleep with a 5-second polling loop that exits as @@ -830,7 +823,7 @@ Worth surfacing for contributors: `tests/merkle_node_helper.go`, `pkg/backend/dist_memory_test_helpers.go::EnableHTTPForTest` (build tag `test`). - **Lint discipline:** 35 `nolint` directives total across the repo, each with a one-line justification. - golangci-lint v2.12.2 runs clean with `--build-tags test`. + golangci-lint v2.13.1 runs clean with `--build-tags test`. ### Removed diff --git a/Makefile b/Makefile index c5ebf46..5896ad0 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ include .project-settings.env -GOLANGCI_LINT_VERSION ?= v2.12.2 -BUF_VERSION ?= v1.70.0 -GO_VERSION ?= 1.26.5 +GOLANGCI_LINT_VERSION ?= v2.13.1 +BUF_VERSION ?= v1.72.0 +GO_VERSION ?= 1.27.0 GCI_PREFIX ?= github.com/hyp3rd/hypercache PROTO_ENABLED ?= true diff --git a/go.mod b/go.mod index f7e96cc..3d7db88 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,48 @@ module github.com/hyp3rd/hypercache -go 1.26.5 +go 1.27.0 require ( github.com/cespare/xxhash/v2 v2.3.0 github.com/coreos/go-oidc/v3 v3.20.0 github.com/go-jose/go-jose/v4 v4.1.4 github.com/goccy/go-json v0.10.6 - github.com/gofiber/fiber/v3 v3.4.0 + github.com/gofiber/fiber/v3 v3.5.0 github.com/hyp3rd/ewrap v1.5.1 github.com/hyp3rd/sectools v1.2.8 - github.com/redis/go-redis/v9 v9.21.0 - github.com/stretchr/testify v1.11.1 - github.com/ugorji/go/codec v1.3.1 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/metric v1.44.0 - go.opentelemetry.io/otel/sdk v1.44.0 - go.opentelemetry.io/otel/sdk/metric v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 - golang.org/x/crypto v0.54.0 + github.com/redis/go-redis/v9 v9.22.0 + github.com/stretchr/testify v1.12.1 + github.com/ugorji/go/codec v1.3.2 + go.opentelemetry.io/otel v1.46.0 + go.opentelemetry.io/otel/metric v1.46.0 + go.opentelemetry.io/otel/sdk v1.46.0 + go.opentelemetry.io/otel/sdk/metric v1.46.0 + go.opentelemetry.io/otel/trace v1.46.0 + golang.org/x/crypto v0.55.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/andybalholm/brotli v1.2.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/andybalholm/brotli v1.2.3 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/gofiber/schema v1.8.3 // indirect - github.com/gofiber/utils/v2 v2.2.0 // indirect + github.com/gofiber/schema v1.8.4 // indirect + github.com/gofiber/utils/v2 v2.4.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/klauspost/compress v1.19.1 // indirect + github.com/klauspost/compress v1.19.2 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect - github.com/mattn/go-isatty v0.0.23 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.72.0 // indirect + github.com/valyala/fasthttp v1.73.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.57.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index 108bed1..8cbbf5c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= -github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ= +github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -8,10 +8,9 @@ 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/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= -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/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= -github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -21,12 +20,12 @@ 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.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw= -github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk= -github.com/gofiber/schema v1.8.3 h1:06ZedxIYjngzc0095PYy7uWnFnbRflWFpikvZH61fDc= -github.com/gofiber/schema v1.8.3/go.mod h1:jWnnZdhcW1mHyV+VnfRxKJDPNcepJsTZ9RIWxrr32Ng= -github.com/gofiber/utils/v2 v2.2.0 h1:YSSmCzQponq/f9uSOg2HtXC5qK1Dmor0o6DqaQVz8GE= -github.com/gofiber/utils/v2 v2.2.0/go.mod h1:Ieopk6sQh7rbhQ12aBNCJtJuG0gxAg0nz63sFCrrOmE= +github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8= +github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w= +github.com/gofiber/schema v1.8.4 h1:ctANnOE2uXft17l5cw78qYqoLt2nfZGRgZ2QUugefFQ= +github.com/gofiber/schema v1.8.4/go.mod h1:JxOlqaEBpuyGKBLI9wY8BAsnWt9z+cFGLaijlAF/IF0= +github.com/gofiber/utils/v2 v2.4.2 h1:OA4LAEG3zKkPwFU7Jippxdk//N5hKMU12qXSCd0C4lQ= +github.com/gofiber/utils/v2 v2.4.2/go.mod h1:AGosyllO+RVdfXyyKoUp91D0qj86BuknRN4I6mciqe8= 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= @@ -35,8 +34,8 @@ github.com/hyp3rd/ewrap v1.5.1 h1:rnLaig+rnpBYkL7vQsvLUJGQpCLa/Yl5RRAnWjphJPs= github.com/hyp3rd/ewrap v1.5.1/go.mod h1:Pbote45XDYyodYzdcUH7xnWmnI6SSewbOYtTlRSsfvw= github.com/hyp3rd/sectools v1.2.8 h1:JuZXI+0ttXzJ3DoRYpgi1fupYFGOUv0zsdSMQRwVAX8= github.com/hyp3rd/sectools v1.2.8/go.mod h1:AUIx7NT3YjZI+ykQikURwpYvQsOqVIJ98rBtMYDo8sk= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -45,28 +44,26 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= -github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= 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.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= -github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= 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.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8= github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= -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/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= -github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= -github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= +github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M= -github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk= +github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s= +github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k= 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= @@ -75,34 +72,36 @@ 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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= +go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= +go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU= +go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= +go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= +go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/client/errors.go b/pkg/client/errors.go index dcc92fb..903bb37 100644 --- a/pkg/client/errors.go +++ b/pkg/client/errors.go @@ -141,9 +141,7 @@ func isRetryable(err error) bool { return false } - var se *StatusError - - if errors.As(err, &se) { + if se, ok := errors.AsType[*StatusError](err); ok { // 5xx and 503 retry; other 4xx are terminal. 503/draining // is special-cased: even when the server is technically // returning a valid response, the right thing to do is diff --git a/tests/dist_http_limits_test.go b/tests/dist_http_limits_test.go index c2b2456..050b8b0 100644 --- a/tests/dist_http_limits_test.go +++ b/tests/dist_http_limits_test.go @@ -107,9 +107,8 @@ func TestDistHTTPClient_RejectsOversizedResponse(t *testing.T) { // http.MaxBytesReader returns *http.MaxBytesError; the transport // wraps the decode error so we just check the error chain. - var maxBytesErr *http.MaxBytesError - if !errors.As(err, &maxBytesErr) { + if _, ok := errors.AsType[*http.MaxBytesError](err); !ok { t.Fatalf("expected http.MaxBytesError in error chain, got: %v", err) } } From 4278ac880c81d4d80c4bbea11d57b025c314d981 Mon Sep 17 00:00:00 2001 From: "F." Date: Fri, 28 Aug 2026 20:33:05 +0300 Subject: [PATCH 2/2] chore(docker): bump go and distroless base image versions Update hypercache-server Dockerfile to build with Go 1.27 and use gcr.io/distroless/static-debian13 as the final runtime base image. --- cmd/hypercache-server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/hypercache-server/Dockerfile b/cmd/hypercache-server/Dockerfile index f8d8f92..49e1614 100644 --- a/cmd/hypercache-server/Dockerfile +++ b/cmd/hypercache-server/Dockerfile @@ -2,7 +2,7 @@ # Multi-stage build: produce a small, distroless final image. # Build stage uses the matching Go toolchain pinned in go.mod. -ARG GO_VERSION=1.26 +ARG GO_VERSION=1.27 FROM golang:${GO_VERSION}-alpine AS build @@ -25,7 +25,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ ./cmd/hypercache-server # Final stage: distroless static, no shell, no package manager. -FROM gcr.io/distroless/static-debian12:nonroot +FROM gcr.io/distroless/static-debian13:nonroot COPY --from=build /out/hypercache-server /hypercache-server