mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
The runtime_brief_slim feature flag has burned in; the slim runtime brief is now the sole path. - execenv: buildMetaSkillContent / BuildCommentReplyInstructions delegate to the slim assembler unconditionally; delete the legacy verbose brief body and writeBackgroundTaskSafetyInstructions. - Remove the runtime_brief_slim flag and the daemon-bound flag delivery subsystem built solely for it: execenv flag wiring (runtime_config_flag.go, server_snapshot_provider.go), the featureflagdispatch package, the DaemonFeatureFlagSnapshot heartbeat protocol field, and the server/daemon wiring in router.go, handler, daemon.go, main.go, cmd_daemon.go. - Keep the generic server/pkg/featureflag engine (still used by composio_mcp_apps). - Update tests to slim-only expectations and docs/feature-flags.md. Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
508 lines
18 KiB
Go
508 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/multica-ai/multica/server/internal/analytics"
|
|
"github.com/multica-ai/multica/server/internal/daemonws"
|
|
"github.com/multica-ai/multica/server/internal/events"
|
|
"github.com/multica-ai/multica/server/internal/handler"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
|
|
"github.com/multica-ai/multica/server/internal/realtime"
|
|
"github.com/multica-ai/multica/server/internal/scheduler"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/featureflag"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
var (
|
|
version = "dev"
|
|
commit = "unknown"
|
|
)
|
|
|
|
func newNamedRedisClient(base *redis.Options, suffix string) *redis.Client {
|
|
opts := *base
|
|
if envBool("REDIS_DISABLE_CLIENT_NAME", false) {
|
|
opts.ClientName = ""
|
|
} else {
|
|
opts.ClientName = redisClientName(opts.ClientName, suffix)
|
|
}
|
|
return redis.NewClient(&opts)
|
|
}
|
|
|
|
func redisClientName(existing, suffix string) string {
|
|
if suffix == "" {
|
|
return existing
|
|
}
|
|
if existing != "" {
|
|
return existing + ":" + suffix
|
|
}
|
|
return "multica-api:" + suffix
|
|
}
|
|
|
|
func closeRedisClient(label string, client *redis.Client) {
|
|
if client == nil {
|
|
return
|
|
}
|
|
if err := client.Close(); err != nil {
|
|
slog.Warn("redis client close failed", "client", label, "error", err)
|
|
}
|
|
}
|
|
|
|
func shardedRelayConfigFromEnv() realtime.ShardedStreamRelayConfig {
|
|
cfg := realtime.DefaultShardedStreamRelayConfig()
|
|
cfg.Shards = envPositiveInt("REALTIME_RELAY_SHARDS", cfg.Shards)
|
|
cfg.StreamMaxLen = envPositiveInt64("REALTIME_RELAY_STREAM_MAXLEN", cfg.StreamMaxLen)
|
|
cfg.ReadCount = envPositiveInt64("REALTIME_RELAY_XREAD_COUNT", cfg.ReadCount)
|
|
cfg.ReadBlock = envDuration("REALTIME_RELAY_XREAD_BLOCK", cfg.ReadBlock)
|
|
cfg.ReplayGrace = envDuration("REALTIME_RELAY_REPLAY_GRACE", cfg.ReplayGrace)
|
|
return cfg
|
|
}
|
|
|
|
func realtimeRelayModeFromEnv() string {
|
|
const defaultMode = "sharded"
|
|
raw := strings.ToLower(strings.TrimSpace(os.Getenv("REALTIME_RELAY_MODE")))
|
|
if raw == "" {
|
|
return defaultMode
|
|
}
|
|
switch raw {
|
|
case "sharded", "dual", "legacy":
|
|
return raw
|
|
default:
|
|
slog.Warn("invalid env var, using default", "name", "REALTIME_RELAY_MODE", "value", raw, "default", defaultMode)
|
|
return defaultMode
|
|
}
|
|
}
|
|
|
|
func envPositiveInt(name string, def int) int {
|
|
raw := os.Getenv(name)
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
v, err := strconv.Atoi(raw)
|
|
if err != nil || v <= 0 {
|
|
slog.Warn("invalid env var, using default", "name", name, "value", raw, "default", def, "error", err)
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func envPositiveInt64(name string, def int64) int64 {
|
|
raw := os.Getenv(name)
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
v, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil || v <= 0 {
|
|
slog.Warn("invalid env var, using default", "name", name, "value", raw, "default", def, "error", err)
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func envDuration(name string, def time.Duration) time.Duration {
|
|
raw := os.Getenv(name)
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
v, err := time.ParseDuration(raw)
|
|
if err != nil || v <= 0 {
|
|
slog.Warn("invalid env var, using default", "name", name, "value", raw, "default", def.String(), "error", err)
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func envBool(name string, def bool) bool {
|
|
raw := os.Getenv(name)
|
|
if raw == "" {
|
|
return def
|
|
}
|
|
v, err := strconv.ParseBool(raw)
|
|
if err != nil {
|
|
slog.Warn("invalid env var, using default", "name", name, "value", raw, "default", def, "error", err)
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func main() {
|
|
logger.Init()
|
|
|
|
// Warn about missing configuration
|
|
if os.Getenv("JWT_SECRET") == "" {
|
|
slog.Warn("JWT_SECRET is not set — using insecure default. Set JWT_SECRET for production use.")
|
|
}
|
|
if os.Getenv("RESEND_API_KEY") == "" && strings.TrimSpace(os.Getenv("SMTP_HOST")) == "" {
|
|
slog.Warn("no email backend configured (RESEND_API_KEY and SMTP_HOST both empty) — verification codes will be printed to the log instead of emailed.")
|
|
}
|
|
if os.Getenv("MULTICA_DEV_VERIFICATION_CODE") != "" {
|
|
if strings.EqualFold(strings.TrimSpace(os.Getenv("APP_ENV")), "production") {
|
|
slog.Warn("MULTICA_DEV_VERIFICATION_CODE is set but ignored because APP_ENV=production.")
|
|
} else {
|
|
slog.Warn("MULTICA_DEV_VERIFICATION_CODE is enabled. Use it only for local development or private test instances.")
|
|
}
|
|
}
|
|
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
|
|
// Feature flags: loaded once at startup from MULTICA_FEATURE_FLAGS_FILE
|
|
// (a YAML rule set) with FF_<KEY> env overrides layered on top.
|
|
// See docs/feature-flags.md for the schema and lifecycle rules.
|
|
//
|
|
// Booting the server without any flag config is intentional: when the
|
|
// env var is unset, every IsEnabled call falls through to the caller's
|
|
// default, so existing code paths are unchanged until someone adds a
|
|
// rule. A misconfigured (malformed / missing) file surfaces as a hard
|
|
// error so operators see misconfig the same way they do for any other
|
|
// MULTICA_*_FILE knob.
|
|
flags, err := featureflag.NewServiceFromEnv(featureflag.WithLogger(slog.Default()))
|
|
if err != nil {
|
|
slog.Error("feature flag configuration failed to load", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
_ = flags // adopted by the router (opts.FeatureFlags) and server-side toggle points; see docs/feature-flags.md
|
|
|
|
dbURL := os.Getenv("DATABASE_URL")
|
|
if dbURL == "" {
|
|
dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
|
|
}
|
|
|
|
// Connect to database
|
|
ctx := context.Background()
|
|
pool, err := newDBPool(ctx, dbURL)
|
|
if err != nil {
|
|
slog.Error("unable to connect to database", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
defer pool.Close()
|
|
|
|
if err := pool.Ping(ctx); err != nil {
|
|
slog.Error("unable to ping database", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("connected to database")
|
|
logPoolConfig(pool)
|
|
|
|
bus := events.New()
|
|
hub := realtime.NewHub()
|
|
go hub.Run()
|
|
daemonHub := daemonws.NewHub()
|
|
var daemonWakeup service.TaskWakeupNotifier = daemonHub
|
|
|
|
// MUL-1138: when REDIS_URL is set, route fanout through a Redis relay so
|
|
// multiple API nodes can deliver each other's events. Without it the hub
|
|
// is the sole broadcaster and the server stays single-node (legacy).
|
|
// Runtime local-skill stores and realtime relay traffic use separate Redis
|
|
// clients so blocking stream consumers cannot starve request-path Redis
|
|
// operations.
|
|
relayCtx, relayCancel := context.WithCancel(context.Background())
|
|
var broadcaster realtime.Broadcaster = hub
|
|
var storeRedis *redis.Client
|
|
var relayWriteRedis *redis.Client
|
|
var relayReadRedis *redis.Client
|
|
var shardedReadRedis *redis.Client
|
|
var legacyReadRedis *redis.Client
|
|
var relay realtime.ManagedRelay
|
|
defer func() {
|
|
if relay != nil {
|
|
relay.Stop()
|
|
}
|
|
relayCancel()
|
|
if relay != nil {
|
|
relay.Wait()
|
|
}
|
|
closeRedisClient("realtime-read-legacy", legacyReadRedis)
|
|
closeRedisClient("realtime-read-sharded", shardedReadRedis)
|
|
closeRedisClient("realtime-read", relayReadRedis)
|
|
closeRedisClient("realtime-write", relayWriteRedis)
|
|
closeRedisClient("store", storeRedis)
|
|
}()
|
|
if redisURL := os.Getenv("REDIS_URL"); redisURL != "" {
|
|
opts, err := redis.ParseURL(redisURL)
|
|
if err != nil {
|
|
slog.Error("invalid REDIS_URL — falling back to in-memory hub", "error", err)
|
|
} else {
|
|
if envBool("REDIS_DISABLE_CLIENT_NAME", false) {
|
|
slog.Info("redis: CLIENT SETNAME disabled (REDIS_DISABLE_CLIENT_NAME=true) for managed Redis compatibility")
|
|
}
|
|
storeRedis = newNamedRedisClient(opts, "store")
|
|
relayWriteRedis = newNamedRedisClient(opts, "realtime-write")
|
|
|
|
relayMode := realtimeRelayModeFromEnv()
|
|
relayConfig := shardedRelayConfigFromEnv()
|
|
switch relayMode {
|
|
case "legacy":
|
|
relayReadRedis = newNamedRedisClient(opts, "realtime-read")
|
|
relay = realtime.NewRedisRelayWithClients(hub, relayWriteRedis, relayReadRedis)
|
|
slog.Info("daemon websocket wakeup: Redis fanout disabled in legacy realtime relay mode")
|
|
case "dual":
|
|
shardedReadRedis = newNamedRedisClient(opts, "realtime-read-sharded")
|
|
legacyReadRedis = newNamedRedisClient(opts, "realtime-read-legacy")
|
|
sharded := realtime.NewShardedStreamRelay(hub, relayWriteRedis, shardedReadRedis, relayConfig)
|
|
sharded.SetDaemonRuntimeDeliverer(daemonHub)
|
|
legacy := realtime.NewRedisRelayWithClients(hub, relayWriteRedis, legacyReadRedis)
|
|
relay = realtime.NewMirroredRelay(sharded, legacy)
|
|
daemonWakeup = daemonws.NewRelayNotifier(daemonHub, sharded)
|
|
default:
|
|
relayReadRedis = newNamedRedisClient(opts, "realtime-read")
|
|
sharded := realtime.NewShardedStreamRelay(hub, relayWriteRedis, relayReadRedis, relayConfig)
|
|
sharded.SetDaemonRuntimeDeliverer(daemonHub)
|
|
relay = sharded
|
|
daemonWakeup = daemonws.NewRelayNotifier(daemonHub, sharded)
|
|
}
|
|
relay.Start(relayCtx)
|
|
broadcaster = realtime.NewDualWriteBroadcaster(hub, relay)
|
|
slog.Info(
|
|
"realtime: Redis relay enabled",
|
|
"node_id", relay.NodeID(),
|
|
"mode", relayMode,
|
|
"shards", relayConfig.Shards,
|
|
"stream_max_len", relayConfig.StreamMaxLen,
|
|
"xread_count", relayConfig.ReadCount,
|
|
"xread_block", relayConfig.ReadBlock.String(),
|
|
"store_pool_size", opts.PoolSize,
|
|
"realtime_write_pool_size", opts.PoolSize,
|
|
"realtime_read_pool_size", opts.PoolSize,
|
|
)
|
|
}
|
|
} else {
|
|
slog.Info("realtime: REDIS_URL not set — using in-memory hub (single-node mode)")
|
|
}
|
|
registerListeners(bus, broadcaster)
|
|
|
|
analyticsClient := analytics.NewFromEnv()
|
|
defer analyticsClient.Close()
|
|
|
|
queries := db.New(pool)
|
|
hub.SetAuthorizer(newScopeAuthorizer(queries))
|
|
// Order matters: subscriber listeners must register BEFORE notification listeners.
|
|
// The notification listener queries the subscriber table to determine recipients,
|
|
// so subscribers must be written first within the same synchronous event dispatch.
|
|
registerSubscriberListeners(bus, queries)
|
|
registerActivityListeners(bus, queries)
|
|
registerNotificationListeners(bus, queries)
|
|
|
|
metricsConfig := obsmetrics.ConfigFromEnv()
|
|
var metricsServer *http.Server
|
|
var httpMetrics *obsmetrics.HTTPMetrics
|
|
var businessMetrics *obsmetrics.BusinessMetrics
|
|
var samplerPool *pgxpool.Pool
|
|
if metricsConfig.Enabled() {
|
|
// Build a dedicated tiny pool for the BusinessSamplerCollector
|
|
// so a stalled scrape can never starve business traffic. If the
|
|
// pool fails to construct we log and continue without the
|
|
// sampler — the rest of /metrics is still useful.
|
|
var err error
|
|
samplerPool, err = newSamplerDBPool(ctx, dbURL)
|
|
if err != nil {
|
|
slog.Warn("metrics: failed to build sampler pgxpool; sampler disabled", "error", err)
|
|
samplerPool = nil
|
|
}
|
|
|
|
metricsRegistry := obsmetrics.NewRegistry(obsmetrics.RegistryOptions{
|
|
Pool: pool,
|
|
Realtime: realtime.M,
|
|
DaemonWS: daemonws.M,
|
|
Version: version,
|
|
Commit: commit,
|
|
BusinessSampler: func() *obsmetrics.BusinessSamplerOptions {
|
|
if samplerPool == nil {
|
|
return nil
|
|
}
|
|
return &obsmetrics.BusinessSamplerOptions{Pool: samplerPool}
|
|
}(),
|
|
})
|
|
httpMetrics = metricsRegistry.HTTP
|
|
businessMetrics = metricsRegistry.Business
|
|
// Forward inbound daemon WS frames into the per-kind counter so
|
|
// dashboards can split heartbeat / unknown / invalid traffic.
|
|
if daemonHub != nil {
|
|
daemonHub.SetMessageKindRecorder(businessMetrics)
|
|
}
|
|
metricsServer = obsmetrics.NewServer(metricsConfig.Addr, metricsRegistry.Gatherer)
|
|
if !obsmetrics.IsLoopbackAddr(metricsConfig.Addr) {
|
|
slog.Warn(
|
|
"metrics listener is not loopback-only; restrict access with private networking, allowlists, or proxy auth",
|
|
"addr", metricsConfig.Addr,
|
|
)
|
|
}
|
|
}
|
|
if samplerPool != nil {
|
|
defer samplerPool.Close()
|
|
}
|
|
|
|
// Construct the BatchedHeartbeatScheduler before the router so it can
|
|
// be injected into the Handler. The Run goroutine starts below
|
|
// alongside the sweeper, and Stop is called explicitly during graceful
|
|
// shutdown so any pending bumps are flushed before we exit.
|
|
heartbeatScheduler := handler.NewBatchedHeartbeatScheduler(queries, handler.DefaultHeartbeatBatchInterval)
|
|
|
|
r, h := NewRouterWithOptions(pool, hub, bus, analyticsClient, storeRedis, RouterOptions{
|
|
HTTPMetrics: httpMetrics,
|
|
BusinessMetrics: businessMetrics,
|
|
DaemonHub: daemonHub,
|
|
DaemonWakeup: daemonWakeup,
|
|
FeatureFlags: flags,
|
|
HeartbeatScheduler: heartbeatScheduler,
|
|
})
|
|
|
|
srv := &http.Server{
|
|
Addr: ":" + port,
|
|
Handler: r,
|
|
}
|
|
|
|
// Start background workers.
|
|
sweepCtx, sweepCancel := context.WithCancel(context.Background())
|
|
autopilotCtx, autopilotCancel := context.WithCancel(context.Background())
|
|
taskSvc := service.NewTaskService(queries, pool, hub, bus, daemonWakeup)
|
|
taskSvc.Analytics = analyticsClient
|
|
taskSvc.Metrics = businessMetrics
|
|
autopilotSvc := service.NewAutopilotService(queries, pool, bus, taskSvc)
|
|
registerAutopilotListeners(bus, autopilotSvc)
|
|
|
|
// Construct a LivenessStore that mirrors the one wired into the HTTP
|
|
// handler. Both the heartbeat write path (handler) and the sweeper read
|
|
// path (here) must agree on the same Redis-or-Noop choice; if they
|
|
// disagree, online runtimes get falsely marked offline.
|
|
var liveness handler.LivenessStore = handler.NewNoopLivenessStore()
|
|
if storeRedis != nil {
|
|
liveness = handler.NewRedisLivenessStore(storeRedis)
|
|
}
|
|
|
|
// Start background sweeper to mark stale runtimes as offline.
|
|
go runRuntimeSweeper(sweepCtx, queries, liveness, taskSvc, bus)
|
|
go heartbeatScheduler.Run(sweepCtx)
|
|
go runAutopilotFailureMonitor(autopilotCtx, queries, bus, envFailureMonitorConfig())
|
|
go runDBStatsLogger(sweepCtx, pool)
|
|
|
|
// Channel inbound supervisor (MUL-3620): holds the §4.4 WS lease per
|
|
// installation and drives each channel.Channel. It is built
|
|
// unconditionally (it is channel-agnostic, not Lark-specific), so it
|
|
// always exists here; with no platform registered or no installation
|
|
// rows it simply idles. Lifecycle is bound to sweepCtx so it winds down
|
|
// alongside the other long-running workers, AFTER the HTTP server has
|
|
// drained.
|
|
if h.ChannelSupervisor != nil {
|
|
go h.ChannelSupervisor.Run(sweepCtx)
|
|
}
|
|
|
|
// MUL-2957: DB-backed execution scheduler. The scheduler turns the
|
|
// `sys_cron_executions` table into the distributed lease + audit
|
|
// log for internal periodic jobs. The first job is
|
|
// `rollup_task_usage_hourly`, which replaces the previously
|
|
// operator-registered `pg_cron` entry (still safe to run
|
|
// concurrently — the SQL function holds advisory lock 4246).
|
|
//
|
|
// A failure to register the job is treated as fatal here only at
|
|
// the registration step (a duplicate name is the only realistic
|
|
// cause and indicates a code bug). Once running, the manager
|
|
// surfaces transient errors — DB unreachable, sys_cron_executions
|
|
// missing because of an unusual partial-migration state — by
|
|
// logging them on the tick that fails and retrying on the next
|
|
// cycle, so a temporary outage does not crash the server.
|
|
schedulerMgr := scheduler.NewManager(pool, scheduler.Options{})
|
|
if err := schedulerMgr.Register(scheduler.TaskUsageHourlyJob(pool)); err != nil {
|
|
slog.Warn("scheduler: failed to register task_usage_hourly rollup job", "error", err)
|
|
}
|
|
// MUL-3551: scheduled-Autopilot dispatch runs on the same DB-backed
|
|
// scheduler. The job owns its plan_times via PlansForScope (each
|
|
// trigger has its own cron expression, so the Cadence planner does
|
|
// not fit). Crash recovery, occurrence-level idempotency, lease
|
|
// theft, and retry are all reused from the manager + sys_cron_executions
|
|
// — there is no separate goroutine for scheduled Autopilot anymore.
|
|
if err := schedulerMgr.Register(scheduler.AutopilotScheduleDispatchJob(pool, queries, autopilotSvc)); err != nil {
|
|
slog.Warn("scheduler: failed to register autopilot_schedule_dispatch job", "error", err)
|
|
}
|
|
go func() {
|
|
_ = schedulerMgr.Run(sweepCtx)
|
|
}()
|
|
|
|
if metricsServer != nil {
|
|
go func() {
|
|
slog.Info("metrics server starting", "addr", metricsConfig.Addr)
|
|
if err := metricsServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("metrics server disabled after startup error", "error", err)
|
|
}
|
|
}()
|
|
}
|
|
|
|
go func() {
|
|
slog.Info("server starting", "port", port)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("server error", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
<-quit
|
|
|
|
slog.Info("shutting down server")
|
|
autopilotCancel()
|
|
|
|
// Order matters: drain in-flight HTTP first so any heartbeat handlers
|
|
// finish calling Schedule() before we stop the scheduler. Otherwise a
|
|
// late heartbeat could enqueue a pending ID after Run has already
|
|
// drained and exited, and Stop() would not flush it.
|
|
apiShutdownCtx, apiShutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
if err := srv.Shutdown(apiShutdownCtx); err != nil {
|
|
apiShutdownCancel()
|
|
slog.Error("server forced to shutdown", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
apiShutdownCancel()
|
|
|
|
// HTTP is fully drained — safe to stop the sweeper and flush the
|
|
// final batch of queued heartbeat bumps.
|
|
sweepCancel()
|
|
heartbeatScheduler.Stop()
|
|
|
|
// Join the channel supervisor's per-installation goroutines so the
|
|
// lease renewer can issue a final release before process exit;
|
|
// otherwise the next replica would have to wait the full LeaseTTL
|
|
// before picking up the installation on the other side of the
|
|
// redeploy. The wait is bounded — if a supervisor is wedged (DB
|
|
// pool stalled, a connector ignoring ctx, etc.) the fallback is the
|
|
// natural LeaseTTL expiry on the other side, which is strictly better
|
|
// than holding shutdown open forever. Then drain the Feishu runtime:
|
|
// the supervisors have stopped delivering inbound events, so flush the
|
|
// debounced run triggers and join any in-flight outbound replies
|
|
// (each bounded by ReplyTimeout) so a binding card / offline notice is
|
|
// not lost on shutdown.
|
|
if h.ChannelSupervisor != nil {
|
|
if !h.ChannelSupervisor.WaitWithTimeout(h.ChannelSupervisor.ShutdownTimeout()) {
|
|
slog.Warn("channel supervisor: connections did not exit within shutdown timeout; proceeding",
|
|
"timeout", h.ChannelSupervisor.ShutdownTimeout().String(),
|
|
)
|
|
}
|
|
if h.ChannelRouter != nil {
|
|
h.ChannelRouter.Drain()
|
|
}
|
|
}
|
|
|
|
if metricsServer != nil {
|
|
metricsShutdownCtx, metricsShutdownCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
if err := metricsServer.Shutdown(metricsShutdownCtx); err != nil {
|
|
slog.Error("metrics server forced to shutdown", "error", err)
|
|
}
|
|
metricsShutdownCancel()
|
|
}
|
|
slog.Info("server stopped")
|
|
}
|