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/
This commit is contained in:
2026-03-19 13:29:28 +00:00
commit eda54153d6
13 changed files with 490 additions and 0 deletions

View File

@@ -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"
}

38
.gitignore vendored Normal file
View File

@@ -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

29
CHANGELOG.md Normal file
View File

@@ -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

92
CLAUDE.md Normal file
View File

@@ -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.

21
LICENSE Normal file
View File

@@ -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.

8
compliance_test.go Normal file
View File

@@ -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{})

3
doc.go Normal file
View File

@@ -0,0 +1,3 @@
// Package valkey provides a Valkey (Redis-compatible) client with launcher lifecycle
// and health check integration.
package valkey

View File

@@ -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.

View File

@@ -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).

12
go.mod Normal file
View File

@@ -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

20
go.sum Normal file
View File

@@ -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=

92
valkey.go Normal file
View File

@@ -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 }

46
valkey_test.go Normal file
View File

@@ -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
}