Running a Server
ACOR ships no server binary. acor/server gives you an http.Handler and a
*grpc.Server; the main that wires them to a collection and listens is yours. This page
is that main, in full, for each protocol.
The
acor/servermodule is experimental and separately versioned.
go get github.com/skyoo2003/acor/server
go get github.com/skyoo2003/acor/pkg/acor@latest
Both lines matter. acor/server resolves to a pseudo-version from main and carries a
require on the core module that Go will not override from the dependency’s own replace
directive — so name the core version yourself.
HTTP
The collection is the service. Every method server.Service requires — Add, Remove,
Find, FindIndex, Suggest, SuggestIndex, Flush, Info — is already a method on
*acor.AhoCorasick, so no adapter is needed.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/skyoo2003/acor/pkg/acor"
"github.com/skyoo2003/acor/server"
"github.com/skyoo2003/acor/server/health"
)
// redisChecker reports whether the collection can still reach Redis.
//
// Info() is the only exported call that proves the Redis path works, and it is
// not free: on V2 it HGETALLs the trie hash and unmarshals the whole keyword and
// prefix arrays just to count them. See "Readiness costs what Info() costs".
//
// The timeout is load-bearing: Check() runs inline in both probe paths, so a
// checker that blocks blocks the prober.
type redisChecker struct{ ac *acor.AhoCorasick }
func (c redisChecker) Check() health.CheckResult {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, err := c.ac.InfoContext(ctx); err != nil {
return health.CheckResult{Status: health.StatusUnhealthy, Details: err.Error()}
}
return health.CheckResult{Status: health.StatusHealthy}
}
func main() {
ac, err := acor.Create(&acor.AhoCorasickArgs{
Addr: os.Getenv("REDIS_ADDR"),
Password: os.Getenv("REDIS_PASSWORD"),
Name: "production",
})
if err != nil {
log.Fatalf("create collection: %v", err)
}
defer ac.Close()
checker := health.NewChecker()
checker.Register("redis", redisChecker{ac})
mux := http.NewServeMux()
health.RegisterHTTPHandlers(mux, checker) // /healthz and /readyz
mux.Handle("/", server.NewHTTPHandler(ac)) // /v1/*
srv := &http.Server{
Addr: ":8080",
Handler: mux,
// ReadHeaderTimeout alone leaves the body unbounded in time: a client
// that sends good headers and then trickles bytes holds a goroutine
// indefinitely. The 1 MiB cap bounds size, not duration.
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("serve: %v", err)
}
}()
log.Println("listening on", srv.Addr)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
Why the mux is built by hand
server.NewHTTPServer(addr, service) is a one-liner that does most of this, and it is the
right choice if you do not need readiness checks. What it cannot give you is /readyz:
NewHTTPHandler builds its own private ServeMux internally, so there is nothing for you
to register on.
Composing them on an outer mux works and does not panic — the two /healthz registrations
live on different muxes and never meet, and on the outer mux Go routes /healthz to the
health package because an exact pattern outranks the / catch-all:
| Path | Served by | Body |
|---|---|---|
/healthz | server/health — shadows the API’s built-in one | {"status":"ok"} |
/readyz | server/health | {"status":"healthy","checks":{...}} |
/v1/* | server.NewHTTPHandler | see HTTP API |
The two /healthz implementations return the same body for a GET, so the shadowing costs
nothing. They differ only in rejecting a non-GET: server/health replies text/plain
via http.Error, the API’s own replies in JSON.
gRPC
package main
import (
"context"
"log"
"net"
"os"
"os/signal"
"syscall"
"time"
"github.com/skyoo2003/acor/pkg/acor"
"github.com/skyoo2003/acor/server"
"github.com/skyoo2003/acor/server/health"
"github.com/skyoo2003/acor/server/logging"
"github.com/skyoo2003/acor/server/metrics"
)
// The deadline matters more here than over HTTP: the gRPC health poller calls
// Check() inline on its ticker, so a checker that blocks stalls every later poll
// and the poller's own response to cancellation.
type redisChecker struct{ ac *acor.AhoCorasick }
func (c redisChecker) Check() health.CheckResult {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, err := c.ac.InfoContext(ctx); err != nil {
return health.CheckResult{Status: health.StatusUnhealthy, Details: err.Error()}
}
return health.CheckResult{Status: health.StatusHealthy}
}
func main() {
// This ctx bounds the background health-status poller as well as shutdown:
// cancelling it stops that goroutine and marks the server NOT_SERVING.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ac, err := acor.Create(&acor.AhoCorasickArgs{
Addr: os.Getenv("REDIS_ADDR"),
Password: os.Getenv("REDIS_PASSWORD"),
Name: "production",
})
if err != nil {
log.Fatalf("create collection: %v", err)
}
defer ac.Close()
checker := health.NewChecker()
checker.Register("redis", redisChecker{ac})
srv := server.NewGRPCServerWithObservability(ctx, ac, &server.Observability{
Metrics: metrics.NewRegistry(nil),
Logger: logging.NewLogger(os.Stdout, "info"),
Health: checker,
// Tracer is nil here, which skips tracing. See Operations → Monitoring.
})
lis, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatalf("listen: %v", err)
}
go func() {
<-ctx.Done()
// GracefulStop waits for every in-flight RPC with no deadline, and
// grpc.health.v1.Watch is a stream that stays open: the health server's
// Shutdown only pushes NOT_SERVING to watchers, it does not close them.
// One connected watcher would otherwise block shutdown forever.
stopped := make(chan struct{})
go func() {
srv.GracefulStop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(15 * time.Second):
srv.Stop()
}
}()
log.Println("gRPC listening on", lis.Addr())
if err := srv.Serve(lis); err != nil {
log.Fatalf("serve: %v", err)
}
}
Every field of Observability is optional — a nil field skips that pillar, so you can
start with logging only. server.NewGRPCServer(service, opts...) is the same server with
none of it.
Prometheus metrics registered here are collected but not exposed: gRPC has no
/metrics endpoint. Serve promhttp.Handler() on a separate HTTP listener, per
Monitoring.
Readiness costs what Info() costs
Info() is the only exported call that touches Redis and returns quickly on a small
collection, which is why it is the readiness check here. It is not a ping: on V2 it runs
HGETALL against the trie hash and unmarshals the complete keyword and prefix arrays to
return their two lengths, so cost and allocations scale with the whole dictionary.
Two things multiply that:
/readyzruns the checkers on every request, and it is unauthenticated.- The gRPC health poller runs them every 5 seconds, whether or not anyone is probing —
and more often once a check gets slow. Its
time.Tickerkeeps ticking whileCheckis blocked and buffers one tick, so an overrunning check is followed immediately by the next. Slow checks are not throttled; they compound.
On a large dictionary that is significant Redis traffic and garbage, generated hardest
exactly when the service is already struggling. If that describes your collection, make
the readiness check a direct redis.Client.Ping against the same address — a PING,
not a dictionary scan — and accept that it proves connectivity rather than collection
health.
Keep it out of liveness either way. /healthz answers “is this process alive”, and a
Redis outage that failed every replica’s liveness probe would restart all of them and
repair nothing. Redis reachability is a readiness signal.
What you still have to decide
- Listen address.
:8080and:9090are placeholders. - Redis credentials and topology.
Addris standalone; Sentinel and Cluster useAddrs, and Sentinel also needsMasterName. See Deployment. - TLS. Neither constructor configures it. For gRPC pass
grpc.Creds(...); for HTTP useListenAndServeTLSor terminate at your ingress. - Authentication. There is none. Both surfaces expose
/v1/flushandFlush, which delete every key in the collection. Do not put either on a network you do not control. - Which protocol to serve. They are independent — run one, the other, or both on separate listeners.