Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ What that does and does not promise:
The [durability and clustering docs](https://skyoo2003.github.io/kvs/docs/clustering/) go through
each of these, and what to do about them.

## Compatibility

What `v1` promises not to break, and what it deliberately leaves out, is on the
[compatibility page](https://skyoo2003.github.io/kvs/docs/compatibility/). Read it before
pinning kvs, and before opening a port: kvs has no authorization and no TLS, only the RESP
listener can be given a password, and HTTP and gRPC listen on every interface by default.

## Documentation

Full documentation is available at [skyoo2003.github.io/kvs](https://skyoo2003.github.io/kvs).
Expand Down
167 changes: 167 additions & 0 deletions api_surface_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package kvs

import (
"bytes"
"flag"
"fmt"
"go/ast"
"go/parser"
"go/printer"
"go/token"
"os"
"strings"
"testing"
)

// goldenPath holds the exported surface of this package. It is the machine-readable half of
// content/docs/compatibility.md: the page says what is promised, this file says what is there.
const goldenPath = "testdata/api-surface.txt"

// headerSep ends the human-facing preamble of the golden file. Everything after it is the
// surface itself, so the warning can be reworded without touching the comparison.
const headerSep = "# ---\n"

const surfaceHeader = `# Exported API surface of this package - see content/docs/compatibility.md
# for how much of it v1 promises, and for the cluster plumbing it exempts by name.
# A line changed or removed below is a breaking change and needs a major version, unless the
# page exempts it. A line added is a new promise: it cannot be taken back within v1.
# Regenerate deliberately: go test -run TestPublicAPISurface . -update
` + headerSep

var updateSurface = flag.Bool("update", false, "rewrite "+goldenPath+" from the current source")

// TestPublicAPISurface fails when the exported surface of this package changes, so that
// widening or breaking the v1 promise is a deliberate act rather than a side effect of some
// other edit. Doc comments are deliberately not part of it: the parser is told to skip them,
// so rewording one costs nothing here.
func TestPublicAPISurface(t *testing.T) {
got := exportedSurface(t)

if *updateSurface {
if err := os.MkdirAll("testdata", 0o750); err != nil {
t.Fatalf("create testdata: %v", err)
}

if err := os.WriteFile(goldenPath, []byte(surfaceHeader+got), 0o600); err != nil {
t.Fatalf("write %s: %v", goldenPath, err)
}

t.Logf("wrote %s", goldenPath)

return
}

raw, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read %s: %v (regenerate with -update)", goldenPath, err)
}

_, want, ok := strings.Cut(string(raw), headerSep)
if !ok {
t.Fatalf("%s has no %q separator; regenerate with -update", goldenPath, headerSep)
}

if got != want {
t.Errorf("exported API surface changed.\n%s\n\n"+
"A changed or removed line is a breaking change unless compatibility.md exempts it;\n"+
"an added line is a new promise. See content/docs/compatibility.md. If deliberate:\n"+
" go test -run TestPublicAPISurface . -update",
firstDifference(want, got))
}
}

// exportedSurface renders every exported declaration in the package directory, one block per
// file in name order so the output is stable across runs.
func exportedSurface(t *testing.T) string {
t.Helper()

entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read package directory: %v", err)
}

fset := token.NewFileSet()

var buf bytes.Buffer

for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}

// Mode 0 leaves comments unparsed, which is what keeps a reworded doc comment from
// failing this test: only the declarations are the promise.
file, err := parser.ParseFile(fset, name, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", name, err)
}

// FileExports drops unexported declarations, struct fields, and interface methods.
// It keeps the bodies of the exported functions, which is why signaturesOnly runs
// after it: how a method is written is not what callers depend on.
if !ast.FileExports(file) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve exported structs' comparability in the snapshot

Because ast.FileExports removes unexported fields, the snapshot records ReadTx only as an opaque struct even though its current fields make the exported type comparable. If a later minor release adds an unexported slice, map, or function field, downstream code that compares ReadTx values or uses them as map keys stops compiling, but this guard produces no diff. Retain enough type information to record comparability, or type-check exported structs and include that property in the snapshot.

Useful? React with 👍 / 👎.

continue
}

file.Decls = signaturesOnly(file.Decls)

fmt.Fprintf(&buf, "==== %s ====\n", name)

if err := printer.Fprint(&buf, fset, file); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize non-API syntax before comparing

Normalize receiver and parameter identifiers before printing the snapshot. Those identifiers are not part of Go source compatibility, but the generated text includes names such as s, tx, and fn, so a harmless rename like s to store fails this test and is reported as a breaking change requiring a major version.

Useful? React with 👍 / 👎.

t.Fatalf("print %s: %v", name, err)
}

buf.WriteString("\n\n")
}

// One trailing newline, which is what the repository's end-of-file hook normalises the
// golden file to. Generating anything else makes committing it fail this test.
return strings.TrimRight(buf.String(), "\n") + "\n"
}

// signaturesOnly drops the import block and every function body, leaving the declarations a
// caller can actually depend on. Without it an edit to the inside of an exported method would
// read as a change to the promise.
func signaturesOnly(decls []ast.Decl) []ast.Decl {
kept := decls[:0]

for _, decl := range decls {
switch d := decl.(type) {
case *ast.GenDecl:
if d.Tok == token.IMPORT {
continue
Comment on lines +131 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve import identities in the API snapshot

Keep enough import information to identify qualified types. After this block removes imports, a dependency migration from one import path to another package with the same local package name leaves signatures such as pkg.Type textually unchanged, even though callers see a distinct Go type and may no longer compile; the compatibility guard therefore misses that breaking change.

Useful? React with 👍 / 👎.

}
case *ast.FuncDecl:
d.Body = nil
}

kept = append(kept, decl)
}

return kept
}

// firstDifference points at the first line that does not match. The whole change is in
// `git diff` on the golden file; what a failing run needs is where to start looking.
func firstDifference(want, got string) string {
wantLines, gotLines := strings.Split(want, "\n"), strings.Split(got, "\n")

for i := 0; i < len(wantLines) || i < len(gotLines); i++ {
wantLine, gotLine := lineAt(wantLines, i), lineAt(gotLines, i)
if wantLine != gotLine {
return fmt.Sprintf("first difference at line %d:\n promised: %q\n found: %q",
i+1, wantLine, gotLine)
}
}

return "no line differs, but the text does (trailing whitespace?)"
}

func lineAt(lines []string, i int) string {
if i >= len(lines) {
return ""
}

return lines[i]
}
5 changes: 5 additions & 0 deletions changes/unreleased/Added-20260809-000000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Added
body: A compatibility page states what v1 promises not to break, what it leaves out, and the trust boundary kvs assumes; the exported Go surface is pinned in testdata/api-surface.txt and checked by a test, so it cannot widen or narrow unnoticed
time: 2026-08-09T00:00:00.000000+09:00
custom:
Issue: "244"
5 changes: 5 additions & 0 deletions changes/unreleased/Fixed-20260810-100000.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: Fixed
body: An HTTP 405 now carries the same JSON error body as every other HTTP error, instead of an empty response the documented contract did not allow
time: 2026-08-10T10:00:00.000000+09:00
custom:
Issue: "244"
2 changes: 1 addition & 1 deletion content/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ surfacing later. What a cluster does and does not promise is on the

### Configuration without flags

Every flag has a config file and environment equivalent. The RESP password has **only**
Every `serve` flag has a config file and environment equivalent. The RESP password has **only**
those: a credential passed as an argument is visible to anything that can list processes.

| Setting | Config key | Environment |
Expand Down
133 changes: 133 additions & 0 deletions content/docs/compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
---
title: "Compatibility"
weight: 6
---

A `v1` tag is a promise: what this page lists will not break until `v2`, and what it does not
list may change in any release. This is the page to read before pinning kvs in something you
have to keep running.

Versions follow [Semantic Versioning](https://semver.org/). Within `v1.x.y`, a new minor
release may add to the list below and a patch release may only fix behaviour that already
contradicts it.

## What v1 covers

### The wire protocols

All three protocols share one keyspace, and that stays true for the type all three can carry: a
string written over HTTP is readable over RESP and gRPC, and the reverse. RESP's lists, hashes,
sets, and sorted sets have no HTTP or gRPC representation, so asking either for a key holding
one is an error rather than a value.

| Protocol | Promised | Details |
|---|---|---|
| HTTP | `PUT`, `GET`, `DELETE` on `/v1/keys/{key}`, and `/healthz` — paths, methods, request and response bodies, status codes | [HTTP API](../http-api/) |
Comment thread
skyoo2003 marked this conversation as resolved.
| gRPC | The service defined by the protobuf files under `api/kvsv1`, and the standard gRPC health service | `api/kvsv1` |
| RESP2 | The commands listed on the Redis API page, with the replies documented there | [Redis API](../redis-api/) |

For gRPC the `.proto` file is the contract. The Go identifiers `protoc` generates from it
follow `protoc`'s own rules, so they are promised only insofar as the proto is.

A RESP command that is not on the Redis API page is not promised, even if the server happens
to answer it.

### The command line

Every flag on the [CLI Usage](../cli/) page keeps its name, its meaning, and its default. Each
`kvs serve` setting keeps the config-file key and environment variable that set it too;
Comment on lines +37 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Qualify --join or follow leader redirects

When --join points to a follower, which the CLI page's “a node already in the cluster” wording and the README's “any existing node” wording explicitly allow, that node answers MOVED, but requestJoin only calls expectOK and joinCluster retries the same follower forever instead of following the supplied leader address. The new guarantee that every flag retains its documented meaning therefore promises a join flow that does not work in this common failover scenario; either follow MOVED or restrict the promised target to the current leader.

Useful? React with 👍 / 👎.

`--config`, `--version`, and `--help` have neither, because the CLI answers those itself rather
than passing them to the server. `kvs serve` with no flags
keeps asking for `:3456` for HTTP, `:3457` for gRPC, and `127.0.0.1:6379` for RESP — the
addresses are the promise, not that each one binds, because a default RESP port already taken
is logged and skipped rather than being fatal. `kvs version` keeps printing a version.

New flags may be added. Existing ones will not change under you.

### The Go library

Importing `github.com/skyoo2003/kvs` gets you:

- `Store` and its constructors `NewStore` and `Open`, with `Get`, `Put`, `Delete`, `Read`,
`Write`, `Snapshot`, `Speculate`, `Watch`, `SetCodec`, and `Close`
- The transaction types `ReadTx` and `Tx`, and `Watch`
- `Entry`, and the `Codec` interface with `StringCodec`
- The sentinel errors `ErrKeyNotFound`, `ErrNoCodec`, `ErrUnsupportedValue`, and `ErrNotLeader`
with `NotLeaderError`

The exact signatures live in [`testdata/api-surface.txt`][surface], which is generated from
the source and compared against it by a test on every run. Nothing can join or leave that file
without the change showing up in review.

[surface]: https://github.com/skyoo2003/kvs/blob/main/testdata/api-surface.txt
Comment thread
skyoo2003 marked this conversation as resolved.

## What v1 does not cover

**Cluster plumbing on `Store`.** `SetReplicator`, `ReplaceWith`, and `ApplyReplicated` are
exported so `internal/cluster` can reach them across the package boundary, not for callers
importing this package. They may change or disappear in a minor release.

**`github.com/skyoo2003/kvs/pkg/resp`.** It exists so the server can speak RESP2, not as a
RESP library for other programs. The protocol kvs answers on the wire is promised; this Go
package is not.

**Anything under `internal/`.** The Go toolchain already stops you importing it; this is the
same statement in words.

**The on-disk format.** The append log in `--data-dir`, and the Raft directory beside it, carry
no format version yet. Data written by one release is not promised to be readable by another.
Until that changes, treat an upgrade as: drain, start the new version against an empty
directory, reload.

**Performance.** Throughput, latency, and memory are not part of the promise. kvs is built for
not losing writes, not for being fast, and a release may trade one for the other.

**The Go version.** kvs builds with the Go release named in `go.mod`. A minor release may
require a newer one.

## Breaking it anyway

Something on this page can only be removed or changed in `v2`. Before that it gets deprecated
in a `v1.x` release — still working, marked in its documentation and in the release notes —
and stays that way until the next major.

One exception: a fix for a security vulnerability may break a promise in a minor release. When
that happens the release notes say so explicitly, in those words.

## Trust boundary

**kvs has no authorization and no TLS, and only one of its three listeners can ask who you
are.** Set `resp_password` in the config file or `KVS_RESP_PASSWORD` in the environment — there
is deliberately no flag, because a flag puts the password in the process list — and the RESP
listener requires `AUTH` before it answers anything, cluster joins included, since `--join` goes
through that listener rather than a second port with its own credentials. HTTP and gRPC have
nothing of the kind: anyone who can open a connection to either can read, change, and delete
everything in the keyspace, and no listener encrypts anything, so a RESP password crosses the
wire in the clear.

The defaults reflect that unevenly, and it is worth knowing which is which:

| Listener | Default | Reachable from | Can require a password |
|---|---|---|---|
| HTTP | `:3456` | every interface | No |
| gRPC | `:3457` | every interface | No |
| RESP | `127.0.0.1:6379` | loopback only | Yes, with `resp_password` |

RESP defaults to loopback because port 6379 is scanned continuously across the public
internet. HTTP and gRPC do not, so a kvs started with defaults on a machine with a public
address is exposed on two ports.

Run kvs on a network you trust, or put something that authenticates in front of it. Do not put
a listener on a public address.

This is a description of v1, not a permanent position. Giving the other two listeners the same
option adds to the promise rather than breaking it, so it can arrive in a `v1.x` release.

## Further Reading

- [Overview](../overview/) — installation and library usage
- [CLI Usage](../cli/) — every flag, config key, and environment variable
- [HTTP API](../http-api/) — REST endpoint details
- [Redis API](../redis-api/) — supported RESP commands and behaviour notes
- [Durability and Clustering](../clustering/) — what `--data-dir` and `--raft-addr` promise
- [Release Process](../release/) — how a version gets cut
2 changes: 1 addition & 1 deletion content/docs/contributing.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Contributing"
weight: 6
weight: 7
---

Contributions are welcome! See [`CONTRIBUTING.md`](https://github.com/skyoo2003/kvs/blob/main/CONTRIBUTING.md) for the full guide.
Expand Down
5 changes: 5 additions & 0 deletions content/docs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ func main() {
}
```

## Compatibility

What `v1` promises not to break, what it deliberately leaves out, and the trust boundary kvs
assumes are on the [Compatibility](../compatibility/) page.

## License

MIT License. See [LICENSE](https://github.com/skyoo2003/kvs/blob/main/LICENSE) for details.
2 changes: 1 addition & 1 deletion content/docs/release.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Release Process"
weight: 7
weight: 8
---

The project uses [Changie](https://github.com/miniscruff/changie) for changelog management and [GoReleaser](https://goreleaser.com/) for automated releases.
Expand Down
4 changes: 2 additions & 2 deletions internal/server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func NewHTTPHandler(store *kvs.Store) http.Handler {

func (h *httpHandler) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exempt HEAD from the JSON error-body promise

When a client sends HEAD /healthz or HEAD /v1/keys/key, this branch selects the new 405 JSON response, but Go's net/http transport suppresses response bodies for HEAD requests. The compatibility page now promises response bodies and the HTTP API says every error has JSON, while the added test exercises only POST through httptest.ResponseRecorder, so real HEAD clients still receive an empty 405; either narrow the documented guarantee for HEAD or handle it explicitly.

Useful? React with 👍 / 👎.

return
}

Expand All @@ -64,7 +64,7 @@ func (h *httpHandler) handleKey(w http.ResponseWriter, r *http.Request) {
case http.MethodDelete:
h.handleDelete(w, key)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
}
}

Expand Down
Loading
Loading