First G1+ piece. Standalone vectord service with in-memory HNSW
indexes keyed by string IDs and optional opaque JSON metadata.
Wraps github.com/coder/hnsw v0.6.1 (pure Go, no cgo). New port
:3215 with /v1/vectors/* routed through gateway.
API:
POST /v1/vectors/index create
GET /v1/vectors/index list
GET /v1/vectors/index/{name} get info
DELETE /v1/vectors/index/{name}
POST /v1/vectors/index/{name}/add (batch)
POST /v1/vectors/index/{name}/search
Acceptance smoke 7/7 PASS — including recall=1 on inserted vector
w-042 (cosine distance 5.96e-8, float32 precision noise), 200-
vector batch round-trip, dim mismatch → 400, missing index → 404,
duplicate create → 409.
Two upstream library quirks worked around in the wrapper:
1. coder/hnsw.Add panics with "node not added" on re-adding an
existing key (length-invariant fires because internal
delete+re-add doesn't change Len). Pre-Delete fixes for n>1.
2. Delete of the LAST node leaves layers[0] non-empty but
entryless; next Add SIGSEGVs in Dims(). Workaround: when
re-adding to a 1-node graph, recreate the underlying graph
fresh via resetGraphLocked().
Cross-lineage scrum on shipped code:
- Opus 4.7 (opencode): 0 BLOCK + 4 WARN + 3 INFO
- Kimi K2-0905 (openrouter): 2 BLOCK + 2 WARN + 1 INFO
- Qwen3-coder (openrouter): "No BLOCKs" (4 tokens)
Fixed (4 real + 2 cleanup):
O-W1: Lookup returned the raw []float32 from coder/hnsw — caller
mutation would corrupt index. Now copies before return.
O-W3: NaN/Inf vectors poison HNSW (distance comparisons return
false for both < and >, breaking heap invariants). Zero-norm
under cosine produces NaN. Now validated at Add time.
K-B1: Re-adding with nil metadata silently cleared the existing
entry — JSON-omitted "metadata" field deserializes as nil,
making upsert non-idempotent. Now nil = "leave alone"; explicit
{} or Delete to clear.
O-W4: Batch Add with mid-batch failure left items 0..N-1
committed and item N rejected. Now pre-validates all IDs+dims
before any Add.
O-I1: jsonItoa hand-roll replaced with strconv.Itoa — no
measured allocation win.
O-I2: distanceFn re-resolved per Search → use stored i.g.Distance.
Dismissed (2 false positives):
K-B2 "MaxBytesReader applied after full read" — false, applied
BEFORE Decode in decodeJSON
K-W1 "Search distances under read lock might see invalidated
slices from concurrent Add" — false, RWMutex serializes
write-lock during Add against read-lock during Search
Deferred (3): HTTP server timeouts (consistent G0 punt),
Content-Type validation (internal service behind gateway), Lookup
dim assertion (in-memory state can't drift).
The K-B1 finding is worth pausing on: nil metadata on re-add is
the kind of API ergonomics bug only a code-reading reviewer
catches — smoke would never detect it because the smoke always
sends explicit metadata. Three lines changed in Add; the resulting
API matches what callers actually expect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
107 lines
3.7 KiB
Go
107 lines
3.7 KiB
Go
// gateway is the Lakehouse-Go HTTP ingress. D6 promotes the D1
|
|
// stub endpoints into real reverse-proxies fronting all four backing
|
|
// services (storaged, catalogd, ingestd, queryd) on a single bind.
|
|
//
|
|
// Routes:
|
|
// /v1/storage/* → storaged
|
|
// /v1/catalog/* → catalogd
|
|
// /v1/ingest → ingestd
|
|
// /v1/sql → queryd
|
|
//
|
|
// The /v1 prefix lives at the edge — internal services route on
|
|
// /storage, /catalog, /ingest, /sql. Per Kimi K2 finding from the
|
|
// D1 plan review: httputil.NewSingleHostReverseProxy preserves the
|
|
// inbound path by default, so the proxy helper strips /v1 in its
|
|
// Director before forwarding.
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/gateway"
|
|
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
|
|
)
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "lakehouse.toml", "path to TOML config")
|
|
flag.Parse()
|
|
|
|
cfg, err := shared.LoadConfig(*configPath)
|
|
if err != nil {
|
|
slog.Error("config", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
upstreams := map[string]string{
|
|
"storaged_url": cfg.Gateway.StoragedURL,
|
|
"catalogd_url": cfg.Gateway.CatalogdURL,
|
|
"ingestd_url": cfg.Gateway.IngestdURL,
|
|
"queryd_url": cfg.Gateway.QuerydURL,
|
|
"vectord_url": cfg.Gateway.VectordURL,
|
|
}
|
|
for k, v := range upstreams {
|
|
if v == "" {
|
|
slog.Error("config", "err", "gateway."+k+" is required")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Per scrum O-WARN2 (Opus): url.Parse is permissive — a typo
|
|
// like "127.0.0.1:3211" (missing scheme) parses without error
|
|
// but produces empty Host, and every proxied request 502s. Fail
|
|
// fast at startup if scheme/host are missing so misconfigs
|
|
// surface in `systemctl status gateway` rather than at first traffic.
|
|
storagedURL := mustParseUpstream("storaged_url", cfg.Gateway.StoragedURL)
|
|
catalogdURL := mustParseUpstream("catalogd_url", cfg.Gateway.CatalogdURL)
|
|
ingestdURL := mustParseUpstream("ingestd_url", cfg.Gateway.IngestdURL)
|
|
querydURL := mustParseUpstream("queryd_url", cfg.Gateway.QuerydURL)
|
|
vectordURL := mustParseUpstream("vectord_url", cfg.Gateway.VectordURL)
|
|
|
|
storagedProxy := gateway.NewProxyHandler(storagedURL)
|
|
catalogdProxy := gateway.NewProxyHandler(catalogdURL)
|
|
ingestdProxy := gateway.NewProxyHandler(ingestdURL)
|
|
querydProxy := gateway.NewProxyHandler(querydURL)
|
|
vectordProxy := gateway.NewProxyHandler(vectordURL)
|
|
|
|
if err := shared.Run("gateway", cfg.Gateway.Bind, func(r chi.Router) {
|
|
// Storage / catalog have multi-segment paths under their
|
|
// prefix (e.g. /v1/storage/get/<key>). chi's `*` wildcard
|
|
// captures the rest of the path.
|
|
r.Handle("/v1/storage/*", storagedProxy)
|
|
r.Handle("/v1/catalog/*", catalogdProxy)
|
|
// Ingest + sql are single endpoints. We accept any method
|
|
// (GET/POST/etc) and let the backing service decide. ingestd
|
|
// only accepts POST; queryd only accepts POST. Other methods
|
|
// will get the backend's 405.
|
|
r.Handle("/v1/ingest", ingestdProxy)
|
|
r.Handle("/v1/sql", querydProxy)
|
|
// Vector search routes — /v1/vectors/index, /v1/vectors/index/{name}/...
|
|
r.Handle("/v1/vectors/*", vectordProxy)
|
|
}); err != nil {
|
|
slog.Error("server", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// mustParseUpstream parses an upstream URL string and validates that
|
|
// scheme + host are non-empty. Exits the process on failure — gateway
|
|
// can't function without a valid upstream so failing fast is the
|
|
// right call. Per scrum O-WARN2.
|
|
func mustParseUpstream(name, raw string) *url.URL {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
slog.Error("config", "err", "parse "+name+": "+err.Error())
|
|
os.Exit(1)
|
|
}
|
|
if u.Scheme == "" || u.Host == "" {
|
|
slog.Error("config", "err", name+" must include scheme + host (got "+raw+")")
|
|
os.Exit(1)
|
|
}
|
|
return u
|
|
}
|