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
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,57 @@ jobs:
tail -n 40 micro.txt 2>/dev/null || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"

# Fails when cache.proto moves and the Python or Node client was not regenerated
# or a facade method was not added. See docs/design/2026-10-08-polyglot-clients.md.
clients-up-to-date:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install protoc 28.3
run: |
curl -fsSL -o /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v28.3/protoc-28.3-linux-x86_64.zip
sudo unzip -o /tmp/protoc.zip -d /usr/local
protoc --version
- name: Install generators
run: |
python3 -m pip install 'grpcio-tools==1.69.0'
npm ci --prefix clients/node
- name: Regenerated stubs match the tree
run: |
bash scripts/gen-clients.sh
git diff --exit-code -- clients/python/src/supercache/_gen clients/node/src/gen
- name: Facades cover every Cache RPC
run: python3 scripts/check-client-surface.py

clients:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22.x"
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Conformance server
run: go test ./clients/conformance/ -count=1
- name: Python client
run: |
python3 -m pip install -e "./clients/python[dev]"
pytest clients/python/tests -q
- name: Node client
working-directory: clients/node
run: |
npm ci
npm test
npm run build
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,11 @@ Thumbs.db
# Python
__pycache__/
*.py[cod]
clients/python/*.egg-info/
clients/python/src/*.egg-info/
clients/python/.pytest_cache/
clients/python/.venv/

# Node client (stubs under src/gen are committed)
clients/node/node_modules/
clients/node/dist/
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ Owner-only storage + remote Get would scale memory with N but would **break** th
| Peer write path | Owner ACK + **async fan-out to R−1 replicas**; peer failures **logged, not retried, not returned** on Put |
| Persistence | **None** |
| Workload | **Read-heavy** |
| Client API | **gRPC** `Cache` service (KV + Bloom + Set + ZSet) + optional in-process Engine + `cmd/sc` |
| Client API | **gRPC** `Cache` service + in-process Engine + `cmd/sc` + in-repo Python (`clients/python`) and Node (`clients/node`) clients |
| Local store | Custom versioned **LRU** (`pkg/store`) with structure caches for set/zset — **not** stock groupcache |
| Membership | **hashicorp/memberlist** (gossip); optional `WithGossipSecret` |
| Hash ring | Consistent hash with virtual nodes over **cache-node** peers only |
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,38 @@ go run ./cmd/sc

Install: `go install ./cmd/sc`. See [cmd/sc/README.md](./cmd/sc/README.md).

### Python and Node

In-repo clients for the Cache port. They are not published. Do not point them at the peer port.

```bash
# Python 3.11+
python3 -m venv .venv && . .venv/bin/activate
pip install -e ./clients/python
```

```python
import supercache
c = supercache.dial("127.0.0.1:9000")
c.put("cacheonly", "greeting", b"hello")
print(c.get("cacheonly", "greeting"))
c.close()
```

```bash
cd clients/node && npm ci && npm run build
```

```js
import { dial } from "@code0987/supercache";
const c = dial("127.0.0.1:9000");
await c.put("cacheonly", "greeting", "hello");
console.log(Buffer.from(await c.get("cacheonly", "greeting")).toString());
c.close();
```

See [clients/python/README.md](./clients/python/README.md) and [clients/node/README.md](./clients/node/README.md).

### Benchmarks

Local SuperCache vs Redis (multi-trial medians):
Expand Down Expand Up @@ -228,6 +260,8 @@ Apps: `client.DialTLS` with `pkg/tlsconfig.ClientFiles`. See [docs/OPERATIONS.md
| `pkg/membership` | Gossip + ring rebuild |
| `pkg/warmup` | Hot keys, topology handoff (hot then rest), refresh-ahead |
| `pkg/client` | Application gRPC client (KV + Bloom + Set + ZSet + Geo + List + Hash + Counter + JSON + Bitmap + HLL + TopK + CMS + VectorSet + Stream) |
| `clients/python`, `clients/node` | Same Cache contract for Python 3.11+ and Node 22+ |
| `clients/conformance` | Single-node Cache server the language tests dial |
| `pkg/tlsconfig` | TLS/mTLS config from PEM files |
| `cmd/supercache-node` | Node binary (`-demo-keyspace`: cacheonly / set / zset / hash / json / bitmap / vectorset) |
| `cmd/sc` | CLI: get/put/del, bloom, sadd*, z*, geo*, l*, h*, incr/cget, json*, bit*, hlladd/hllcount, topkadd/topklist, cmsincr/cmsquery, vadd*, xadd* |
Expand Down
140 changes: 140 additions & 0 deletions clients/conformance/fixture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Command conformance is a single-node Cache gRPC server for client SDK tests.
//
// go run ./clients/conformance
// addr=127.0.0.1:12345
//
// -tls prints PEM paths. -mtls also requires a client certificate.
// The process serves until SIGINT or SIGTERM. Stdout is only the ready lines.
package main

import (
"fmt"
"sync"
"time"

"google.golang.org/grpc"
"google.golang.org/grpc/credentials"

"github.com/Code0987/supercache/internal/cacheserver"
"github.com/Code0987/supercache/pkg/datasource"
"github.com/Code0987/supercache/pkg/engine"
"github.com/Code0987/supercache/pkg/keyspace"
"github.com/Code0987/supercache/pkg/tlsconfig"
)

// Options selects plaintext or TLS for Listen.
type Options struct {
TLS bool
MTLS bool
}

// TLSMaterial is the PEM set written for a TLS server.
type TLSMaterial struct {
Dir string
CA string
Cert string
Key string
ClientCert string
ClientKey string
}

// Server is a running conformance cache.
type Server struct {
Engine *engine.Engine
Addr string
TLS *TLSMaterial

gs *grpc.Server
once sync.Once
}

// Listen registers one keyspace per mode and serves Cache gRPC on addr.
// addr may be host:0. The bound address is Server.Addr.
func Listen(addr string, opt Options) (*Server, error) {
if opt.MTLS {
opt.TLS = true
}
eng := engine.New()
if err := register(eng); err != nil {
eng.Close()
return nil, err
}
var material *TLSMaterial
var srvOpts []grpc.ServerOption
if opt.TLS {
files, err := writeTLS()
if err != nil {
eng.Close()
return nil, err
}
material = files
cfg, err := tlsconfig.ServerFiles(files.Cert, files.Key, files.CA, opt.MTLS)
if err != nil {
eng.Close()
return nil, err
}
srvOpts = append(srvOpts, grpc.Creds(credentials.NewTLS(cfg)))
}
gs, lis, err := cacheserver.ListenAndServe(addr, eng, srvOpts...)
if err != nil {
eng.Close()
return nil, err
}
return &Server{Engine: eng, Addr: lis.Addr().String(), TLS: material, gs: gs}, nil
}

// Close stops gRPC and the engine. It is safe to call more than once.
func (s *Server) Close() {
if s == nil {
return
}
s.once.Do(func() {
if s.gs != nil {
s.gs.Stop()
}
if s.Engine != nil {
s.Engine.Close()
}
})
}

// ReadyLines is the stdout block the process prints, addr first.
func ReadyLines(addr string, files *TLSMaterial) string {
out := fmt.Sprintf("addr=%s\n", addr)
if files == nil {
return out
}
out += fmt.Sprintf("ca=%s\ncert=%s\nkey=%s\nclient_cert=%s\nclient_key=%s\n",
files.CA, files.Cert, files.Key, files.ClientCert, files.ClientKey)
return out
}

func register(eng *engine.Engine) error {
const mb int64 = 32 << 20
ttl := time.Minute
cfgs := []keyspace.Config{
{Name: "cacheonly", Mode: keyspace.ModeCacheOnly, MaxBytes: mb, TTL: ttl},
{Name: "loadthrough", Mode: keyspace.ModeLoadThrough, MaxBytes: mb, TTL: ttl,
DataSource: datasource.Map{"seeded": []byte("from-source")}},
{Name: "bloom", Mode: keyspace.ModeBloom, MaxBytes: mb, TTL: ttl, BloomBits: 1 << 16, BloomHashes: 3},
{Name: "set", Mode: keyspace.ModeSet, MaxBytes: mb, TTL: ttl},
{Name: "zset", Mode: keyspace.ModeZSet, MaxBytes: mb, TTL: ttl},
{Name: "geo", Mode: keyspace.ModeGeo, MaxBytes: mb, TTL: ttl},
{Name: "list", Mode: keyspace.ModeList, MaxBytes: mb, TTL: ttl},
{Name: "hash", Mode: keyspace.ModeHash, MaxBytes: mb, TTL: ttl},
{Name: "counter", Mode: keyspace.ModeCounter, MaxBytes: mb, TTL: ttl},
{Name: "json", Mode: keyspace.ModeJSON, MaxBytes: mb, TTL: ttl},
{Name: "bitmap", Mode: keyspace.ModeBitmap, MaxBytes: mb, TTL: ttl},
{Name: "hll", Mode: keyspace.ModeHLL, MaxBytes: mb, TTL: ttl},
{Name: "topk", Mode: keyspace.ModeTopK, MaxBytes: mb, TTL: ttl, TopKSize: 10},
{Name: "cms", Mode: keyspace.ModeCMS, MaxBytes: mb, TTL: ttl},
{Name: "vectorset", Mode: keyspace.ModeVectorSet, MaxBytes: mb, TTL: ttl, VectorDim: 4},
{Name: "stream", Mode: keyspace.ModeStream, MaxBytes: mb, TTL: ttl},
}
for _, cfg := range cfgs {
if err := eng.UpdateKeySpace(cfg); err != nil {
return fmt.Errorf("keyspace %s: %w", cfg.Name, err)
}
}
return nil
}
104 changes: 104 additions & 0 deletions clients/conformance/fixture_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"context"
"crypto/x509"
"encoding/pem"
"errors"
"os"
"testing"
"time"

"github.com/Code0987/supercache/pkg/client"
"github.com/Code0987/supercache/pkg/engine"
)

func TestListenKeyspacesAndLoadThrough(t *testing.T) {
srv, err := Listen("127.0.0.1:0", Options{})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
if srv.TLS != nil {
t.Fatal("plaintext server wrote TLS files")
}

got := map[string]string{}
for _, ks := range srv.Engine.KeySpaceSnapshots() {
got[ks.Name] = ks.Mode
}
want := map[string]string{
"cacheonly": "CacheOnly", "loadthrough": "LoadThrough", "bloom": "Bloom",
"set": "Set", "zset": "ZSet", "geo": "Geo", "list": "List", "hash": "Hash",
"counter": "Counter", "json": "JSON", "bitmap": "Bitmap", "hll": "HLL",
"topk": "TopK", "cms": "CMS", "vectorset": "VectorSet", "stream": "Stream",
}
if len(got) != len(want) {
t.Fatalf("keyspaces: got %d want %d (%v)", len(got), len(want), got)
}
for name, mode := range want {
if got[name] != mode {
t.Fatalf("keyspace %s: mode %q want %q", name, got[name], mode)
}
}

ctx := context.Background()
v, err := srv.Engine.Get(ctx, "loadthrough", "seeded")
if err != nil || string(v) != "from-source" {
t.Fatalf("loadthrough seeded: %v %q", err, v)
}
if _, err := srv.Engine.Get(ctx, "loadthrough", "missing"); !errors.Is(err, engine.ErrNotFound) {
t.Fatalf("missing loadthrough key: %v", err)
}

cli, err := client.Dial(ctx, srv.Addr)
if err != nil {
t.Fatal(err)
}
defer cli.Close()
if err := cli.Put(ctx, "cacheonly", "k", []byte("v"), client.WithTTL(time.Second)); err != nil {
t.Fatal(err)
}
gotV, err := cli.Get(ctx, "cacheonly", "k")
if err != nil || string(gotV) != "v" {
t.Fatalf("grpc get: %v %q", err, gotV)
}
}

func TestListenTLSFiles(t *testing.T) {
srv, err := Listen("127.0.0.1:0", Options{MTLS: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
if srv.TLS == nil {
t.Fatal("missing TLS material")
}
for _, path := range []string{srv.TLS.CA, srv.TLS.Cert, srv.TLS.Key, srv.TLS.ClientCert, srv.TLS.ClientKey} {
st, err := os.Stat(path)
if err != nil || st.Size() == 0 {
t.Fatalf("pem %s: %v", path, err)
}
}
raw, err := os.ReadFile(srv.TLS.Cert)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode(raw)
if block == nil || block.Type != "CERTIFICATE" {
t.Fatalf("server pem: %#v", block)
}
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
t.Fatal(err)
}
lines := ReadyLines(srv.Addr, srv.TLS)
if len(lines) < len("addr=") || lines[:5] != "addr=" {
t.Fatalf("ready lines: %q", lines)
}
}

func TestReadyLinesPlaintext(t *testing.T) {
if got := ReadyLines("127.0.0.1:9", nil); got != "addr=127.0.0.1:9\n" {
t.Fatalf("got %q", got)
}
}
Loading
Loading