package logstore import ( "context" "fmt" "time" "github.com/maximhq/bifrost/core/schemas" "gorm.io/driver/postgres" "gorm.io/gorm" ) // PostgresConfig represents the configuration for a Postgres database. type PostgresConfig struct { Host *schemas.EnvVar `json:"host"` Port *schemas.EnvVar `json:"port"` User *schemas.EnvVar `json:"user"` Password *schemas.EnvVar `json:"password"` DBName *schemas.EnvVar `json:"db_name"` SSLMode *schemas.EnvVar `json:"ssl_mode"` MaxIdleConns int `json:"max_idle_conns"` MaxOpenConns int `json:"max_open_conns"` } // newPostgresLogStore creates a new Postgres log store. // // Uses a two-pool lifecycle to avoid SQLSTATE 0A000 ("cached plan must not // change result type"): a throwaway pool runs the version check and schema // migrations and is closed immediately, then a fresh runtime pool is opened // for query traffic and the async index / matview builders. The runtime // pool's connections never see pre-migration schema, so their cached // prepared-plans stay valid for the life of the process. func newPostgresLogStore(ctx context.Context, config *PostgresConfig, logger schemas.Logger) (LogStore, error) { if config == nil { return nil, fmt.Errorf("config is required") } // Validate required config if config.Host == nil || config.Host.GetValue() == "" { return nil, fmt.Errorf("postgres host is required") } if config.Port == nil || config.Port.GetValue() == "" { return nil, fmt.Errorf("postgres port is required") } if config.User == nil || config.User.GetValue() == "" { return nil, fmt.Errorf("postgres user is required") } if config.Password == nil || config.Password.GetValue() == "" { return nil, fmt.Errorf("postgres password is required") } if config.DBName == nil || config.DBName.GetValue() == "" { return nil, fmt.Errorf("postgres db name is required") } if config.SSLMode == nil || config.SSLMode.GetValue() == "" { return nil, fmt.Errorf("postgres ssl mode is required") } dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", config.Host.GetValue(), config.Port.GetValue(), config.User.GetValue(), config.Password.GetValue(), config.DBName.GetValue(), config.SSLMode.GetValue()) openPool := func() (*gorm.DB, error) { return gorm.Open(postgres.New(postgres.Config{DSN: dsn}), &gorm.Config{ Logger: newGormLogger(logger), }) } // closePoolStrict returns the close error so callers can abort startup // when the throwaway migration pool doesn't tear down cleanly — a half- // closed pool weakens the guarantee that no cached plans survive DDL. closePool := func(db *gorm.DB) error { if db == nil { return nil } sqlDB, err := db.DB() if err != nil { return err } return sqlDB.Close() } // Throwaway pool for the version gate and schema migrations. Closing it // before the runtime pool opens guarantees no cached plan survives DDL. mDb, err := openPool() if err != nil { return nil, err } // Postgres version gate: refuse to start below 16 (matviews, partitioning, // and some JSON operators we rely on depend on 16+). var pgVersionNum int if err := mDb.Raw("SELECT current_setting('server_version_num')::int").Scan(&pgVersionNum).Error; err != nil { _ = closePool(mDb) return nil, err } if pgVersionNum < 160000 { _ = closePool(mDb) return nil, fmt.Errorf("postgres version is lower than 16, please upgrade to 16 or higher") } if err := triggerMigrations(ctx, mDb); err != nil { _ = closePool(mDb) return nil, err } if err := closePool(mDb); err != nil { return nil, fmt.Errorf("close migration db connection: %w", err) } // Runtime pool. Opens against post-migration schema. db, err := openPool() if err != nil { return nil, err } // Configure connection pool sqlDB, err := db.DB() if err != nil { closePool(db) return nil, err } // Set MaxIdleConns (default: 5) maxIdleConns := config.MaxIdleConns if maxIdleConns == 0 { maxIdleConns = 5 } sqlDB.SetMaxIdleConns(maxIdleConns) // Set MaxOpenConns (default: 50) maxOpenConns := config.MaxOpenConns if maxOpenConns == 0 { maxOpenConns = 50 } sqlDB.SetMaxOpenConns(maxOpenConns) d := &RDBLogStore{db: db, logger: logger} // Run all index builds sequentially in a single goroutine to prevent // deadlocks from concurrent CREATE INDEX CONCURRENTLY on the same table. // Each function is idempotent and acquires its own advisory lock for // cross-node serialization. Running in a goroutine avoids blocking pod startup. go func() { if db.Dialector.Name() != "postgres" { return } // Acquire advisory lock to serialize GIN index builds across cluster nodes. lock, err := acquireIndexLock(context.Background(), db) if err != nil { // Lock is taken by another node, so we will skip the index build return } defer lock.release(context.Background()) if err := ensureMetadataGINIndex(context.Background(), lock.conn); err != nil { logger.Warn(fmt.Sprintf("logstore: metadata GIN index build failed: %s (queries will still work without the index)", err)) } else { logger.Info("logstore: metadata GIN index is ready") } if err := ensureDashboardEnhancements(context.Background(), lock.conn); err != nil { logger.Warn(fmt.Sprintf("logstore: dashboard enhancements failed: %s (dashboard will still work with partial data)", err)) } else { logger.Info("logstore: dashboard enhancements completed") } if err := ensurePerformanceIndexes(context.Background(), lock.conn); err != nil { logger.Warn(fmt.Sprintf("logstore: performance index build failed: %s (queries will still work without the indexes)", err)) } else { logger.Info("logstore: performance indexes are ready") } }() // Create materialized views and start periodic refresh for dashboard queries. go func() { if db.Dialector.Name() != "postgres" { return } if err := ensureMatViews(context.Background(), db); err != nil { logger.Warn(fmt.Sprintf("logstore: matview creation failed: %s (dashboard queries will use raw tables)", err)) return } if err := refreshMatViews(context.Background(), db); err != nil { logger.Warn(fmt.Sprintf("logstore: initial matview refresh failed: %s", err)) } else { logger.Info("logstore: materialized views are ready") // Signal that matviews are ready for query use. Until this point, // canUseMatView() returns false so all queries use raw tables. d.matViewsReady.Store(true) } startMatViewRefresher(context.Background(), db, 30*time.Second, logger, &d.matViewsReady) }() return d, nil }