feat: enhance completion service with session management and language support
- Introduced session management using Redis for tracking active sessions. - Added session claiming and releasing functionality in the completion manager. - Enhanced HTTP and WebSocket completion endpoints to support multiple languages. - Implemented request timeout and maximum body size configurations for API routes. - Updated client-side code to handle session IDs and language parameters in completion requests. - Improved error handling for unsupported languages and session conflicts. - Added tests for the completion manager to ensure proper session handling and cleanup.
This commit is contained in:
@@ -2,45 +2,103 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"monica-go-completion-backend/internal/api"
|
||||
"monica-go-completion-backend/internal/cluster"
|
||||
"monica-go-completion-backend/internal/completion"
|
||||
"monica-go-completion-backend/internal/lsp"
|
||||
)
|
||||
|
||||
var requestIDSeed atomic.Int64
|
||||
|
||||
type config struct {
|
||||
Port string
|
||||
GoplsPath string
|
||||
WorkspaceDir string
|
||||
AllowOrigin string
|
||||
Port string
|
||||
WorkspaceDir string
|
||||
AllowOrigin string
|
||||
APIToken string
|
||||
RequestTimeout time.Duration
|
||||
MaxBodyBytes int64
|
||||
SessionTTL time.Duration
|
||||
CleanupInterval time.Duration
|
||||
MaxSessions int
|
||||
InstanceID string
|
||||
InstanceURL string
|
||||
EnableRedis bool
|
||||
RedisAddr string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
RedisKeyPrefix string
|
||||
InstanceTTL time.Duration
|
||||
Heartbeat time.Duration
|
||||
Servers []completion.LanguageServerSpec
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
lspClient, err := lsp.NewClient(context.Background(), cfg.GoplsPath, cfg.WorkspaceDir)
|
||||
if err != nil {
|
||||
log.Fatalf("create gopls client failed: %v", err)
|
||||
var registry completion.SessionRegistry
|
||||
if cfg.EnableRedis {
|
||||
var err error
|
||||
registry, err = cluster.NewRedisRegistry(context.Background(), cluster.RedisRegistryConfig{
|
||||
Addr: cfg.RedisAddr,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: cfg.RedisDB,
|
||||
KeyPrefix: cfg.RedisKeyPrefix,
|
||||
InstanceID: cfg.InstanceID,
|
||||
InstanceEndpoint: cfg.InstanceURL,
|
||||
SessionTTL: cfg.SessionTTL,
|
||||
InstanceTTL: cfg.InstanceTTL,
|
||||
HeartbeatInterval: cfg.Heartbeat,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("create redis registry failed: %v", err)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
_ = lspClient.Close()
|
||||
}()
|
||||
|
||||
completionService := completion.NewService(lspClient)
|
||||
manager := completion.NewManager(completion.ManagerConfig{
|
||||
WorkspaceDir: cfg.WorkspaceDir,
|
||||
MaxSessions: cfg.MaxSessions,
|
||||
SessionTTL: cfg.SessionTTL,
|
||||
CleanupInterval: cfg.CleanupInterval,
|
||||
InstanceID: cfg.InstanceID,
|
||||
Registry: registry,
|
||||
}, cfg.Servers, func(ctx context.Context, spec completion.LanguageServerSpec, workspaceDir string) (completion.RuntimeClient, error) {
|
||||
return lsp.NewClient(ctx, lsp.Config{
|
||||
Command: spec.Command,
|
||||
Args: spec.Args,
|
||||
RootPath: workspaceDir,
|
||||
LanguageID: spec.LanguageID,
|
||||
ClientName: "monica-lsp-gateway",
|
||||
})
|
||||
})
|
||||
defer func() {
|
||||
_ = manager.Close()
|
||||
}()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Logger())
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(requestIDMiddleware())
|
||||
router.Use(corsMiddleware(cfg.AllowOrigin))
|
||||
api.RegisterRoutes(router, completionService)
|
||||
router.Use(apiTokenMiddleware(cfg.APIToken))
|
||||
|
||||
api.RegisterRoutes(router, manager, api.RouteOptions{
|
||||
RequestTimeout: cfg.RequestTimeout,
|
||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
@@ -51,7 +109,13 @@ func main() {
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("completion backend listening on :%s", cfg.Port)
|
||||
log.Printf(
|
||||
"lsp gateway listening on :%s, workspace=%s, instanceID=%s, redis=%t",
|
||||
cfg.Port,
|
||||
cfg.WorkspaceDir,
|
||||
cfg.InstanceID,
|
||||
cfg.EnableRedis,
|
||||
)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("http server failed: %v", err)
|
||||
}
|
||||
@@ -61,12 +125,15 @@ func main() {
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("http shutdown failed: %v", err)
|
||||
}
|
||||
if err := manager.Close(); err != nil {
|
||||
log.Printf("lsp manager close failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -74,27 +141,133 @@ func loadConfig() config {
|
||||
if err != nil {
|
||||
cwd = "."
|
||||
}
|
||||
|
||||
return config{
|
||||
Port: getenv("PORT", "8080"),
|
||||
GoplsPath: getenv("GOPLS_PATH", "gopls"),
|
||||
WorkspaceDir: getenv("WORKSPACE_DIR", cwd),
|
||||
AllowOrigin: getenv("CORS_ALLOW_ORIGIN", "*"),
|
||||
Port: getenv("PORT", "8080"),
|
||||
WorkspaceDir: getenv("WORKSPACE_DIR", cwd),
|
||||
AllowOrigin: getenv("CORS_ALLOW_ORIGIN", "*"),
|
||||
APIToken: strings.TrimSpace(os.Getenv("LSP_API_TOKEN")),
|
||||
RequestTimeout: getenvDuration("REQUEST_TIMEOUT", 10*time.Second),
|
||||
MaxBodyBytes: getenvInt64("MAX_BODY_BYTES", 2<<20),
|
||||
SessionTTL: getenvDuration("SESSION_TTL", 20*time.Minute),
|
||||
CleanupInterval: getenvDuration("SESSION_CLEANUP_INTERVAL", 2*time.Minute),
|
||||
MaxSessions: getenvInt("MAX_SESSIONS", 256),
|
||||
InstanceID: getenv("INSTANCE_ID", defaultInstanceID()),
|
||||
InstanceURL: getenv("INSTANCE_URL", "http://127.0.0.1:"+getenv("PORT", "8080")),
|
||||
EnableRedis: getenvBool("ENABLE_REDIS_STICKY", true),
|
||||
RedisAddr: getenv("REDIS_ADDR", "10.0.0.10:6379"),
|
||||
RedisPassword: getenv("REDIS_PASSWORD", ""),
|
||||
RedisDB: getenvInt("REDIS_DB", 1),
|
||||
RedisKeyPrefix: getenv("REDIS_KEY_PREFIX", "lsp-gateway"),
|
||||
InstanceTTL: getenvDuration("INSTANCE_TTL", 30*time.Second),
|
||||
Heartbeat: getenvDuration("INSTANCE_HEARTBEAT_INTERVAL", 10*time.Second),
|
||||
Servers: []completion.LanguageServerSpec{
|
||||
{
|
||||
Language: "go",
|
||||
LanguageID: "go",
|
||||
Command: getenv("GO_LSP_COMMAND", "gopls"),
|
||||
Args: getenvArgs("GO_LSP_ARGS", ""),
|
||||
},
|
||||
{
|
||||
Language: "javascript",
|
||||
LanguageID: "javascript",
|
||||
Command: getenv("JAVASCRIPT_LSP_COMMAND", "typescript-language-server"),
|
||||
Args: getenvArgs("JAVASCRIPT_LSP_ARGS", "--stdio"),
|
||||
},
|
||||
{
|
||||
Language: "typescript",
|
||||
LanguageID: "typescript",
|
||||
Command: getenv("TYPESCRIPT_LSP_COMMAND", "typescript-language-server"),
|
||||
Args: getenvArgs("TYPESCRIPT_LSP_ARGS", "--stdio"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func defaultInstanceID() string {
|
||||
host, err := os.Hostname()
|
||||
if err != nil || strings.TrimSpace(host) == "" {
|
||||
host = "unknown-host"
|
||||
}
|
||||
userName := "unknown-user"
|
||||
if u, err := user.Current(); err == nil && strings.TrimSpace(u.Username) != "" {
|
||||
userName = strings.ReplaceAll(u.Username, "\\", "-")
|
||||
}
|
||||
return fmt.Sprintf("%s-%s-%d", host, userName, os.Getpid())
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
v := os.Getenv(key)
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func getenvArgs(key, fallback string) []string {
|
||||
value := getenv(key, fallback)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(value)
|
||||
}
|
||||
|
||||
func getenvInt(key string, fallback int) int {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(v)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getenvInt64(key string, fallback int64) int64 {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseInt(v, 10, 64)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getenvDuration(key string, fallback time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func getenvBool(key string, fallback bool) bool {
|
||||
v := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
switch v {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func corsMiddleware(allowOrigin string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", allowOrigin)
|
||||
c.Header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-API-Key")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
@@ -103,3 +276,33 @@ func corsMiddleware(allowOrigin string) gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func apiTokenMiddleware(token string) gin.HandlerFunc {
|
||||
required := strings.TrimSpace(token)
|
||||
if required == "" {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
provided := strings.TrimSpace(c.GetHeader("X-API-Key"))
|
||||
if provided == required {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
rid := strings.TrimSpace(c.GetHeader("X-Request-Id"))
|
||||
if rid == "" {
|
||||
rid = fmt.Sprintf("req-%d", requestIDSeed.Add(1))
|
||||
}
|
||||
c.Header("X-Request-Id", rid)
|
||||
c.Set("requestId", rid)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user