- embed posts in a bounded worker pool while preserving index order, with per-post error context and transient (429/5xx) retry backoff - SummarizeStyle errors when the stream ends before a done chunk or the model truncates at its context limit (done_reason length) instead of returning a partial summary for a payable tool - New() drops the unused ctx param and names its model params; callers in main.go updated
186 lines
5.1 KiB
Go
186 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
_ "github.com/lib/pq"
|
|
log "github.com/nacorid/logger"
|
|
"github.com/nacorid/naco-api/internal/activitypub"
|
|
"github.com/nacorid/naco-api/internal/bluesky"
|
|
"github.com/nacorid/naco-api/internal/config"
|
|
"github.com/nacorid/naco-api/internal/nostr"
|
|
"github.com/nacorid/naco-api/internal/ollama"
|
|
"github.com/nacorid/naco-api/internal/server"
|
|
"github.com/nacorid/naco-api/internal/store"
|
|
"github.com/nacorid/naco-api/internal/twitter"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
func main() {
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
// backgroundCtx is for long-lived clients whose lifetime is the process,
|
|
// not a single request. It must not inherit signal cancellation so that a
|
|
// SIGTERM during startup does not poison an in-flight session creation.
|
|
backgroundCtx := context.Background()
|
|
|
|
cfg := config.Load()
|
|
|
|
if err := log.Init(log.Config{
|
|
LogFilePath: cfg.LogFile,
|
|
ConsoleLevel: log.LevelDebug,
|
|
FileLevel: log.LevelTrace,
|
|
}); err != nil {
|
|
log.Fatalf("Failed to init logger: %v", err)
|
|
}
|
|
defer log.Close()
|
|
|
|
conn, err := sql.Open("postgres", cfg.DatabaseURL)
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize database connection: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
conn.SetMaxOpenConns(10)
|
|
conn.SetMaxIdleConns(10)
|
|
conn.SetConnMaxLifetime(5 * time.Minute)
|
|
|
|
dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
err = conn.PingContext(dbCtx)
|
|
dbCancel()
|
|
if err != nil {
|
|
log.Fatalf("Failed to ping database: %v", err)
|
|
}
|
|
db := store.New(conn)
|
|
|
|
if cfg.MigrateOnStart {
|
|
if err := store.Migrate(ctx, conn, cfg.MigrationsDir); err != nil {
|
|
log.Fatalf("Failed to apply migrations: %v", err)
|
|
}
|
|
log.Info("Database migrations applied", "dir", cfg.MigrationsDir)
|
|
}
|
|
|
|
bskyClient, err := bluesky.NewBlueskyClient(backgroundCtx, cfg.BlueskyDID, cfg.BlueskyAppPassword, cfg.BlueskyHost)
|
|
if err != nil {
|
|
log.Fatal("Failed to create bluesky client", "error", err)
|
|
}
|
|
|
|
uri, err := url.Parse(cfg.OllamaHost)
|
|
if err != nil {
|
|
log.Fatal("Invalid OLLAMA_HOST URL", "error", err)
|
|
}
|
|
ollamaHTTPClient := &http.Client{
|
|
Timeout: 600 * time.Second,
|
|
}
|
|
ollamaClient := ollama.New(uri, ollamaHTTPClient, cfg.EmbeddingModel, cfg.StyleModel)
|
|
|
|
apClient, err := activitypub.NewAPClient(cfg.ActivityPubHost, cfg.ActivityPubToken)
|
|
if err != nil {
|
|
log.Fatal("Failed to create activitypub client", "error", err)
|
|
}
|
|
|
|
nostrClient, err := nostr.NewNostrClient(cfg.NostrRelays)
|
|
if err != nil {
|
|
log.Fatal("Failed to create nostr client", "error", err)
|
|
}
|
|
|
|
twitterClient := twitter.NewTwitterClient(cfg.TwitterBearerToken)
|
|
|
|
srv := server.New(ctx, db, apClient, bskyClient, nostrClient, twitterClient, ollamaClient, cfg)
|
|
|
|
srv.StartPrewarm(ctx)
|
|
|
|
g, gCtx := errgroup.WithContext(ctx)
|
|
startReadyzServer(g, ctx, cfg, conn)
|
|
|
|
g.Go(func() error {
|
|
log.Info("Server starting", "address", cfg.ListenAddress)
|
|
if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return fmt.Errorf("main server error: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
g.Go(func() error {
|
|
<-gCtx.Done()
|
|
log.Info("Shutting down main server...")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
return srv.Shutdown(shutdownCtx)
|
|
})
|
|
|
|
if err := g.Wait(); err != nil && !errors.Is(err, context.Canceled) {
|
|
log.Error("Application terminated with error", "error", err)
|
|
}
|
|
|
|
log.Info("Application shutdown complete")
|
|
}
|
|
|
|
func startReadyzServer(g *errgroup.Group, shutdownCtx context.Context, cfg *config.Config, conn *sql.DB) {
|
|
mux := http.NewServeMux()
|
|
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
if err := conn.PingContext(r.Context()); err != nil {
|
|
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, cfg.OllamaHost+"/api/tags", nil)
|
|
if err != nil {
|
|
http.Error(w, "invalid ollama configuration", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil || resp.StatusCode != http.StatusOK {
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
http.Error(w, "ollama unavailable", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
defer func() {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
}()
|
|
|
|
_, _ = w.Write([]byte("ok"))
|
|
})
|
|
|
|
readyServer := &http.Server{
|
|
Addr: cfg.ReadyzAddress + ":" + cfg.ReadyzPort,
|
|
Handler: mux,
|
|
ReadTimeout: 5 * time.Second,
|
|
WriteTimeout: 5 * time.Second,
|
|
}
|
|
|
|
g.Go(func() error {
|
|
log.Info("Readiness server starting", "address", readyServer.Addr)
|
|
if err := readyServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return fmt.Errorf("readiness server failed: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
g.Go(func() error {
|
|
<-shutdownCtx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
return readyServer.Shutdown(shutdownCtx)
|
|
})
|
|
}
|