2 Commits

Author SHA1 Message Date
ee88ebecf2 Add pre/post interceptor hooks to grpcserver.New
New Options parameter with PreInterceptors and PostInterceptors
allows services to inject custom interceptors into the chain:

  [pre-interceptors] → logging → auth → [post-interceptors] → handler

This enables services like metacrypt to add seal-check (pre-auth)
and audit-logging (post-auth) interceptors while using the shared
auth and logging infrastructure.

Pass nil for the default chain (logging + auth only).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:21:29 -07:00
20d8d8d4b4 Set MaxOpenConns(1) to eliminate SQLite SQLITE_BUSY errors
Go's database/sql opens multiple connections by default, but SQLite
only supports one concurrent writer. Under concurrent load (e.g.
parallel blob uploads to MCR), multiple connections compete for the
write lock and exceed busy_timeout, causing transient 500 errors.

With WAL mode, a single connection still allows concurrent reads
from other processes. Go serializes access through the connection
pool, eliminating busy errors entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:28:46 -07:00
3 changed files with 38 additions and 8 deletions

View File

@@ -59,6 +59,12 @@ func Open(path string) (*sql.DB, error) {
} }
} }
// SQLite supports concurrent readers but only one writer. With WAL mode,
// reads don't block writes, but multiple Go connections competing for
// the write lock causes SQLITE_BUSY under concurrent load. Limit to one
// connection to serialize all access and eliminate busy errors.
database.SetMaxOpenConns(1)
// Ensure permissions are correct even if the file already existed. // Ensure permissions are correct even if the file already existed.
if err := os.Chmod(path, 0600); err != nil { if err := os.Chmod(path, 0600); err != nil {
_ = database.Close() _ = database.Close()

View File

@@ -48,21 +48,45 @@ type Server struct {
listener net.Listener listener net.Listener
} }
// Options configures optional behavior for the gRPC server.
type Options struct {
// PreInterceptors run before the logging and auth interceptors.
// Use for lifecycle gates like seal checks that should reject
// requests before any auth validation occurs.
PreInterceptors []grpc.UnaryServerInterceptor
// PostInterceptors run after auth but before the handler.
// Use for audit logging, rate limiting, or other cross-cutting
// concerns that need access to the authenticated identity.
PostInterceptors []grpc.UnaryServerInterceptor
}
// New creates a gRPC server with TLS (if certFile and keyFile are // New creates a gRPC server with TLS (if certFile and keyFile are
// non-empty) and an interceptor chain: logging → auth → handler. // non-empty) and an interceptor chain:
//
// [pre-interceptors] → logging → auth → [post-interceptors] → handler
// //
// The auth interceptor uses methods to determine the access level for // The auth interceptor uses methods to determine the access level for
// each RPC. Methods not in any map are denied by default. // each RPC. Methods not in any map are denied by default.
// //
// If certFile and keyFile are empty, TLS is skipped (for testing). // If certFile and keyFile are empty, TLS is skipped (for testing).
func New(certFile, keyFile string, authenticator *auth.Authenticator, methods MethodMap, logger *slog.Logger) (*Server, error) { // opts is optional; pass nil for the default chain (logging + auth only).
chain := grpc.ChainUnaryInterceptor( func New(certFile, keyFile string, authenticator *auth.Authenticator, methods MethodMap, logger *slog.Logger, opts *Options) (*Server, error) {
var interceptors []grpc.UnaryServerInterceptor
if opts != nil {
interceptors = append(interceptors, opts.PreInterceptors...)
}
interceptors = append(interceptors,
loggingInterceptor(logger), loggingInterceptor(logger),
authInterceptor(authenticator, methods), authInterceptor(authenticator, methods),
) )
if opts != nil {
interceptors = append(interceptors, opts.PostInterceptors...)
}
chain := grpc.ChainUnaryInterceptor(interceptors...)
var opts []grpc.ServerOption var serverOpts []grpc.ServerOption
opts = append(opts, chain) serverOpts = append(serverOpts, chain)
if certFile != "" && keyFile != "" { if certFile != "" && keyFile != "" {
cert, err := tls.LoadX509KeyPair(certFile, keyFile) cert, err := tls.LoadX509KeyPair(certFile, keyFile)
@@ -73,11 +97,11 @@ func New(certFile, keyFile string, authenticator *auth.Authenticator, methods Me
Certificates: []tls.Certificate{cert}, Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13, MinVersion: tls.VersionTLS13,
} }
opts = append(opts, grpc.Creds(credentials.NewTLS(tlsCfg))) serverOpts = append(serverOpts, grpc.Creds(credentials.NewTLS(tlsCfg)))
} }
return &Server{ return &Server{
GRPCServer: grpc.NewServer(opts...), GRPCServer: grpc.NewServer(serverOpts...),
Logger: logger, Logger: logger,
}, nil }, nil
} }

View File

@@ -216,7 +216,7 @@ func TestNewWithoutTLS(t *testing.T) {
defer srv.Close() defer srv.Close()
a := testAuth(t, srv.URL) a := testAuth(t, srv.URL)
s, err := New("", "", a, testMethods, slog.Default()) s, err := New("", "", a, testMethods, slog.Default(), nil)
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }