From eda54153d6528ea253928e59bb45521493fc981d Mon Sep 17 00:00:00 2001 From: Rene Nochebuena Date: Thu, 19 Mar 2026 13:29:28 +0000 Subject: [PATCH] feat(valkey): initial stable release v0.9.0 Valkey (Redis-compatible) client component with launcher lifecycle and health check integration. What's included: - Config with Addrs, Password, SelectDB, CacheSizeEachConn (env-driven) - Provider interface exposing native valkey-go Client() directly (no wrapper) - Component interface: launcher.Component + health.Checkable + Provider - New(logger, cfg) constructor for lifecycle registration via lc.Append - Health check via PING at LevelDegraded priority - Graceful shutdown calling client.Close() in OnStop Tested-via: todo-api POC integration Reviewed-against: docs/adr/ --- .devcontainer/devcontainer.json | 26 ++++++ .gitignore | 38 ++++++++ CHANGELOG.md | 29 ++++++ CLAUDE.md | 92 ++++++++++++++++++++ LICENSE | 21 +++++ compliance_test.go | 8 ++ doc.go | 3 + docs/adr/ADR-001-native-valkey-client.md | 54 ++++++++++++ docs/adr/ADR-002-no-serialization-helpers.md | 49 +++++++++++ go.mod | 12 +++ go.sum | 20 +++++ valkey.go | 92 ++++++++++++++++++++ valkey_test.go | 46 ++++++++++ 13 files changed, 490 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 compliance_test.go create mode 100644 doc.go create mode 100644 docs/adr/ADR-001-native-valkey-client.md create mode 100644 docs/adr/ADR-002-no-serialization-helpers.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 valkey.go create mode 100644 valkey_test.go diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,26 @@ +{ + "name": "Go", + "image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie", + "features": { + "ghcr.io/devcontainers-extra/features/claude-code:1": {} + }, + "forwardPorts": [], + "postCreateCommand": "go version", + "customizations": { + "vscode": { + "settings": { + "files.autoSave": "afterDelay", + "files.autoSaveDelay": 1000, + "explorer.compactFolders": false, + "explorer.showEmptyFolders": true + }, + "extensions": [ + "golang.go", + "eamodio.golang-postfix-completion", + "quicktype.quicktype", + "usernamehw.errorlens" + ] + } + }, + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with go test -c +*.test + +# Output of go build +*.out + +# Dependency directory +vendor/ + +# Go workspace file +go.work +go.work.sum + +# Environment files +.env +.env.* + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# VCS files +COMMIT.md +RELEASE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2e945b3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this module will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.9.0] - 2026-03-18 + +### Added + +- `Config` struct — Valkey connection settings loaded from environment variables: `VK_ADDRS` (required, comma-separated server addresses), `VK_PASSWORD` (authentication password), `VK_DB` (database index, default `0`), `VK_CLIENT_CACHE_MB` (per-connection client-side cache size in MB, default `0` disables caching) +- `Provider` interface — `Client() valkey.Client`; for consumers that only need access to the native `valkey-go` client +- `Component` interface — embeds `launcher.Component`, `health.Checkable`, and `Provider`; the full lifecycle-managed surface registered with the launcher +- `New(logger logz.Logger, cfg Config) Component` — constructor; returns a `Component` ready for `lc.Append` +- `OnInit` — constructs the `valkey-go` client from `Config`; enables client-side caching when `CacheSizeEachConn > 0` +- `OnStart` — verifies connectivity by issuing a `PING` command; returns an error if the ping fails +- `OnStop` — calls `client.Close()` for graceful shutdown +- `HealthCheck(ctx context.Context) error` — issues `PING`; returns an error if the client is nil or the command fails +- `Name() string` — returns `"valkey"` +- `Priority() health.Level` — returns `health.LevelDegraded`; a cache outage degrades service rather than halting it + +### Design Notes + +- The native `valkey-go` client is exposed directly via `Client()` with no wrapping or re-exported command subset; callers use the full command-builder API and own all serialisation and key namespacing. +- `Provider` / `Component` split follows the framework pattern: inject `Provider` into repositories and services, inject `Component` only at the lifecycle registration site. +- Health priority is `LevelDegraded` by design — callers must handle cache misses by falling back to the primary datastore rather than treating a Valkey outage as fatal. + +[0.9.0]: https://code.nochebuena.dev/go/valkey/releases/tag/v0.9.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..05e7a3f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# valkey + +Valkey (Redis-compatible) client with launcher lifecycle and health check integration. + +## Purpose + +Manages the lifecycle of a `valkey-go` client: constructs it from config, verifies +connectivity at startup, exposes the native client to consumers, and closes it on shutdown. +Provides a health check via `PING`. Does not add serialisation, key namespacing, or any +caching policy on top of the native client. + +## Tier & Dependencies + +**Tier 3 (infrastructure)** — depends on: +- `code.nochebuena.dev/go/health` (Tier 2) +- `code.nochebuena.dev/go/launcher` (Tier 2) +- `code.nochebuena.dev/go/logz` (Tier 1) +- `github.com/valkey-io/valkey-go` (native Valkey client) + +Does **not** depend on `xerrors` — errors from the valkey-go client are returned as-is. + +## Key Design Decisions + +- **Native client exposure**: `Client() vk.Client` returns the native `valkey-go` client + directly. No wrapper, no re-exported command subset. Callers use the full valkey-go command + builder API. See ADR-001. +- **No serialisation helpers**: The module has no `SetJSON`, `GetJSON`, or similar helpers. + Callers marshal and unmarshal their own data. See ADR-002. +- **Health priority is Degraded**: `Priority()` returns `health.LevelDegraded`, not + `LevelCritical`. A Valkey outage degrades service but should not always halt it, + depending on whether the caller falls back to the primary datastore. +- **Optional client-side caching**: `Config.CacheSizeEachConn` (in MB) enables valkey-go's + built-in client-side cache. Setting it to 0 (the default) disables the cache entirely. +- **Duck-typed Logger**: The internal `logger` field is typed as `logz.Logger`, the shared + interface from the `logz` module (ADR-001 global pattern). + +## Patterns + +**Lifecycle registration:** +```go +vk := valkey.New(logger, cfg) +lc.Append(vk) // registers OnInit / OnStart / OnStop +``` + +**Accessing the client in a repository:** +```go +type cacheRepo struct { + provider valkey.Provider +} + +func (r *cacheRepo) Get(ctx context.Context, key string) ([]byte, error) { + client := r.provider.Client() + result := client.Do(ctx, client.B().Get().Key(key).Build()) + return result.AsBytes() +} +``` + +**Writing with TTL:** +```go +client := provider.Client() +cmd := client.B().Set().Key(key).Value(val).Ex(300).Build() +if err := client.Do(ctx, cmd).Error(); err != nil { + return err +} +``` + +**Health check registration:** +```go +health.Register(vkComponent) // satisfies health.Checkable via Name()/Priority()/HealthCheck() +``` + +## What to Avoid + +- Do not add serialisation helpers to this module. Keep marshal/unmarshal in the caller or + in a separate cache repository layer. +- Do not define custom interfaces that re-export a subset of `vk.Client` methods here. + If a consumer needs a minimal testable interface, define it in the consumer package. +- Do not call `Client()` before `OnInit` has run — it will return `nil`. +- Do not treat `health.LevelDegraded` as if it were `LevelCritical`. Design callers to + handle cache misses gracefully rather than depending on Valkey for correctness. + +## Testing Notes + +- Unit tests (`valkey_test.go`) do not require a running Valkey server. They test lifecycle + behaviour (nil client safety, name, priority) without real network calls. +- `TestComponent_OnInit_InvalidAddr` verifies that an empty address slice does not panic. + Whether `OnInit` returns an error depends on the valkey-go implementation; the test + documents the accepted behaviour. +- Integration tests requiring a live Valkey instance are outside this module and belong in + a higher-tier test suite. +- `compliance_test.go` (package `valkey_test`) asserts `New(...)` satisfies `Component` + at compile time. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NOCHEBUENADEV + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..0833a5d --- /dev/null +++ b/compliance_test.go @@ -0,0 +1,8 @@ +package valkey_test + +import ( + "code.nochebuena.dev/go/logz" + "code.nochebuena.dev/go/valkey" +) + +var _ valkey.Component = valkey.New(logz.New(logz.Options{}), valkey.Config{}) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..f521416 --- /dev/null +++ b/doc.go @@ -0,0 +1,3 @@ +// Package valkey provides a Valkey (Redis-compatible) client with launcher lifecycle +// and health check integration. +package valkey diff --git a/docs/adr/ADR-001-native-valkey-client.md b/docs/adr/ADR-001-native-valkey-client.md new file mode 100644 index 0000000..e9d1b0d --- /dev/null +++ b/docs/adr/ADR-001-native-valkey-client.md @@ -0,0 +1,54 @@ +# ADR-001: Expose Native valkey.Client Without a Wrapper Layer + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +Some infrastructure modules wrap their underlying client behind a custom interface that +re-exports only the operations they anticipate callers will need. This approach has two +failure modes: + +1. The wrapper becomes a bottleneck: every new operation requires a new method on the + wrapper interface, creating churn. +2. The wrapper diverges from the upstream API surface, forcing callers to learn two APIs. + +Valkey (and the compatible Redis protocol) has a rich, evolving command set. A thin wrapper +that re-exports commands one at a time would either be incomplete or grow unboundedly. + +## Decision + +The `Component` interface exposes the native `vk.Client` directly via `Client() vk.Client`. +Callers receive a `vk.Client` value and use the valkey-go command builder API directly: + +```go +cmd := vkClient.B().Set().Key(key).Value(val).Ex(ttl).Build() +err = vkClient.Do(ctx, cmd).Error() +``` + +The `Provider` interface is the minimal consumer-facing surface: + +```go +type Provider interface { + Client() vk.Client +} +``` + +This module's only responsibilities are: constructing the client from `Config`, verifying +connectivity on `OnStart`, issuing a `PING` for health checks, and closing the client on +`OnStop`. All command execution is delegated entirely to the caller via the native client. + +## Consequences + +**Positive:** +- Callers have access to the full valkey-go API with no intermediary layer. +- No wrapper code to maintain as the valkey-go API evolves. +- The module stays small and focused on lifecycle management. +- Optional client-side caching (`CacheSizeEachConn`) is supported by passing the config + option through to `vk.NewClient` — no wrapper changes needed. + +**Negative:** +- Callers are coupled to the `valkey-go` library's API directly. Switching to a different + Valkey/Redis client would require changes at every call site. +- Mocking in tests requires either an `httptest`-style server or a mock that satisfies the + `vk.Client` interface, which is more complex than mocking a minimal custom interface. diff --git a/docs/adr/ADR-002-no-serialization-helpers.md b/docs/adr/ADR-002-no-serialization-helpers.md new file mode 100644 index 0000000..1124568 --- /dev/null +++ b/docs/adr/ADR-002-no-serialization-helpers.md @@ -0,0 +1,49 @@ +# ADR-002: No Serialisation Helpers — Callers Marshal/Unmarshal Themselves + +**Status:** Accepted +**Date:** 2026-03-18 + +## Context + +Cache and key-value store modules often provide convenience methods such as +`SetJSON(ctx, key, value, ttl)` or `GetJSON(ctx, key, &target)` that handle JSON +marshalling and unmarshalling internally. While convenient, this approach has drawbacks: + +- It encodes a single serialisation format (typically JSON) into the module's API, making + it hard to use binary formats like protobuf or MessagePack for performance-sensitive paths. +- It obscures marshalling errors, which can become hard to distinguish from network errors. +- It requires the module to understand the caller's data types, coupling them together. +- It adds dependencies (e.g. `encoding/json`) that are not needed for all callers. + +## Decision + +The `valkey` module provides no serialisation helpers. It exposes only `Client() vk.Client`, +and all marshal/unmarshal logic lives in the caller: + +```go +// caller marshals before writing +b, err := json.Marshal(myValue) +cmd := client.B().Set().Key(key).Value(string(b)).Ex(ttl).Build() +client.Do(ctx, cmd) + +// caller unmarshals after reading +result := client.Do(ctx, client.B().Get().Key(key).Build()) +b, err := result.AsBytes() +json.Unmarshal(b, &myValue) +``` + +This keeps the module at zero opinion on serialisation format, zero added dependencies +beyond `valkey-go`, and zero abstraction cost. + +## Consequences + +**Positive:** +- Callers choose their own serialisation format with no module-level constraints. +- The module has no encoding/decoding logic that needs testing or maintenance. +- Binary formats, compressed payloads, and plain strings all work identically. + +**Negative:** +- Every caller that stores structured data must implement its own marshal/unmarshal + boilerplate, typically in a repository or cache layer. +- There is no built-in protection against storing data with an incompatible format + (e.g. writing JSON and reading with a protobuf decoder). diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..95ce740 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module code.nochebuena.dev/go/valkey + +go 1.25 + +require ( + code.nochebuena.dev/go/health v0.9.0 + code.nochebuena.dev/go/launcher v0.9.0 + code.nochebuena.dev/go/logz v0.9.0 + github.com/valkey-io/valkey-go v1.0.54 +) + +require golang.org/x/sys v0.29.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4b4fb8c --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +code.nochebuena.dev/go/health v0.9.0 h1:x0UKjC7CHAE3AgwyFzCyjmGJIjoLBBxeOHxXuqpbKwI= +code.nochebuena.dev/go/health v0.9.0/go.mod h1:f3IsNtU60JSn5yXmBBh9XOvr5pRyEah5+wS4tjDQZso= +code.nochebuena.dev/go/launcher v0.9.0 h1:dJHonA9Xm03AQKK0919FJaQn9ZKHZ+RZfB9yxjnx3TA= +code.nochebuena.dev/go/launcher v0.9.0/go.mod h1:IBtntmbnyddukjEhxlc7Ysdzz9nZsnd9+8FzAIHt77g= +code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE= +code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/valkey-io/valkey-go v1.0.54 h1:pmFRGcMRJW8mHvsWLd/2MSgY6i3WNygpUl904KUaxao= +github.com/valkey-io/valkey-go v1.0.54/go.mod h1:NE+C8cjb3+XvLazNhiorcLJGhJa9MBAkFNoAW/48/fk= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/valkey.go b/valkey.go new file mode 100644 index 0000000..e3dfed4 --- /dev/null +++ b/valkey.go @@ -0,0 +1,92 @@ +package valkey + +import ( + "context" + "fmt" + + vk "github.com/valkey-io/valkey-go" + + "code.nochebuena.dev/go/health" + "code.nochebuena.dev/go/launcher" + "code.nochebuena.dev/go/logz" +) + +// Provider is the minimal interface for consumers that only need the valkey client. +type Provider interface { + Client() vk.Client +} + +// Component adds lifecycle management and health check to Provider. +type Component interface { + launcher.Component + health.Checkable + Provider +} + +// Config holds Valkey connection settings. +type Config struct { + Addrs []string `env:"VK_ADDRS,required" envSeparator:","` + Password string `env:"VK_PASSWORD"` + SelectDB int `env:"VK_DB" envDefault:"0"` + CacheSizeEachConn int `env:"VK_CLIENT_CACHE_MB" envDefault:"0"` // MB; 0 = disable +} + +type vkComponent struct { + cfg Config + logger logz.Logger + client vk.Client +} + +// New returns a valkey Component. Call lc.Append(vk) to manage its lifecycle. +func New(logger logz.Logger, cfg Config) Component { + return &vkComponent{cfg: cfg, logger: logger} +} + +func (v *vkComponent) OnInit() error { + opts := vk.ClientOption{ + InitAddress: v.cfg.Addrs, + Password: v.cfg.Password, + SelectDB: v.cfg.SelectDB, + } + if v.cfg.CacheSizeEachConn > 0 { + opts.CacheSizeEachConn = v.cfg.CacheSizeEachConn * 1024 * 1024 + } + client, err := vk.NewClient(opts) + if err != nil { + return fmt.Errorf("valkey: failed to create client: %w", err) + } + v.client = client + return nil +} + +func (v *vkComponent) OnStart() error { + v.logger.Info("valkey: verifying connection") + if v.client == nil { + return fmt.Errorf("valkey: client not initialized") + } + if err := v.client.Do(context.Background(), v.client.B().Ping().Build()).Error(); err != nil { + return fmt.Errorf("valkey: ping failed: %w", err) + } + v.logger.Info("valkey: connected") + return nil +} + +func (v *vkComponent) OnStop() error { + v.logger.Info("valkey: closing client") + if v.client != nil { + v.client.Close() + } + return nil +} + +func (v *vkComponent) Client() vk.Client { return v.client } + +func (v *vkComponent) HealthCheck(ctx context.Context) error { + if v.client == nil { + return fmt.Errorf("valkey: client not initialized") + } + return v.client.Do(ctx, v.client.B().Ping().Build()).Error() +} + +func (v *vkComponent) Name() string { return "valkey" } +func (v *vkComponent) Priority() health.Level { return health.LevelDegraded } diff --git a/valkey_test.go b/valkey_test.go new file mode 100644 index 0000000..ba9a71e --- /dev/null +++ b/valkey_test.go @@ -0,0 +1,46 @@ +package valkey + +import ( + "testing" + + "code.nochebuena.dev/go/health" + "code.nochebuena.dev/go/logz" +) + +func newLogger() logz.Logger { return logz.New(logz.Options{}) } + +func TestNew(t *testing.T) { + if New(newLogger(), Config{}) == nil { + t.Fatal("New returned nil") + } +} + +func TestComponent_Name(t *testing.T) { + c := New(newLogger(), Config{}).(health.Checkable) + if c.Name() != "valkey" { + t.Errorf("want valkey, got %s", c.Name()) + } +} + +func TestComponent_Priority(t *testing.T) { + c := New(newLogger(), Config{}).(health.Checkable) + if c.Priority() != health.LevelDegraded { + t.Error("Priority() != LevelDegraded") + } +} + +func TestComponent_OnStop_NilClient(t *testing.T) { + c := &vkComponent{logger: newLogger()} + if err := c.OnStop(); err != nil { + t.Errorf("OnStop with nil client: %v", err) + } +} + +func TestComponent_OnInit_InvalidAddr(t *testing.T) { + // An empty Addrs slice should cause NewClient to fail or produce an error. + c := New(newLogger(), Config{Addrs: []string{}}) + err := c.OnInit() + // valkey-go may not error on empty addr until first command; + // just verify it doesn't panic and returns a component. + _ = err +}