Files
ragflow/internal/engine/infinity/client.go
qinling0210 a84d2ae3d2 fix(infinity): avoid hangs with pooled connections and bounded timeouts (#17287)
### Summary

**Issue**
When calling Infinity under concurrent load, one shared connection was
reused across concurrent operations, it just hangs.
 

**Solution**
Avoid hangs with pooled connections and bounded timeouts
2026-07-23 20:37:38 +08:00

435 lines
13 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package infinity
import (
"context"
"errors"
"fmt"
"os"
"ragflow/internal/common"
"reflect"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/apache/thrift/lib/go/thrift"
"ragflow/internal/server"
infinity "github.com/infiniflow/infinity-go-sdk"
"go.uber.org/zap"
)
type infinityClient struct {
pool *infinity.ConnectionPool
poolMaxOpen int
dbName string
getDatabaseSeq atomic.Uint64
getDatabaseInflight atomic.Int64
// Original URI from config, used by RunSQL to extract the host.
hostURI string
// Port for psql wire-protocol listener (default 5432).
postgresPort int
// JSON file (under conf/) with the field-name alias map.
mappingFileName string
}
// defaultPoolMaxSize is the hard upper bound (MaxOpen) for the Go Infinity
// connection pool. Invalid or missing INFINITY_POOL_MAX_SIZE values fall back
// to this rather than becoming unbounded.
const defaultPoolMaxSize = 4
// defaultMaxIdleConnections caps how many established connections the pool
// keeps idle for reuse, independent of the pool's hard open cap (MaxOpen).
// It bounds idle socket retention on the Infinity server when MaxOpen is large.
const defaultMaxIdleConnections = 2
// defaultOperationTimeout bounds the total duration of a single pool operation
// (applied via ensureDeadline when the caller context has no deadline).
const defaultOperationTimeout = 30 * time.Second
// defaultSocketTimeout bounds individual thrift read/write waits on a checked-out
// connection.
const defaultSocketTimeout = 30 * time.Second
// resolvePoolMaxSize reads INFINITY_POOL_MAX_SIZE and falls back to defaultPoolMaxSize. A value < 1 is
// rejected so the pool never becomes unbounded.
func resolvePoolMaxSize() int {
raw := os.Getenv("INFINITY_POOL_MAX_SIZE")
if raw == "" {
return defaultPoolMaxSize
}
v, err := strconv.Atoi(raw)
if err != nil || v < 1 {
common.Warn("INFINITY_POOL_MAX_SIZE must be a positive integer; using default",
zap.String("value", raw), zap.Int("default", defaultPoolMaxSize))
return defaultPoolMaxSize
}
return v
}
// NewInfinityClient creates a new Infinity client using the SDK
type poolExhaustedError struct {
caller string
}
func (e *poolExhaustedError) Error() string {
return fmt.Sprintf("Infinity connection pool exhausted while %s", e.caller)
}
func (e *poolExhaustedError) Code() common.ErrorCode {
return common.CodeConnectionPoolExhausted
}
func (e *poolExhaustedError) Message() string {
return e.Error()
}
func ensureDeadline(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if ctx == nil {
return context.WithTimeout(context.Background(), timeout)
}
if _, ok := ctx.Deadline(); ok {
return ctx, func() {}
}
return context.WithTimeout(ctx, timeout)
}
func NewInfinityClient(cfg *server.InfinityConfig) (*infinityClient, error) {
// Parse URI like "localhost:23817" to get IP and port
host := "127.0.0.1"
port := 23817
if cfg.URI != "" {
parts := strings.Split(cfg.URI, ":")
if len(parts) == 2 {
host = parts[0]
if p, err := strconv.Atoi(parts[1]); err == nil {
port = p
}
}
}
// Build the connection pool object. Connections are created lazily on
// first use (InitialSize=0), so this does not verify reachability; the
// actual connectivity check happens later in WaitForHealthy. The retry
// loop only re-attempts pool construction against transient config errors.
common.Info("Connecting to Infinity")
uri := infinity.NetworkAddress{IP: host, Port: port}
maxOpen := resolvePoolMaxSize()
var pool *infinity.ConnectionPool
var err error
for i := 0; i < 24; i++ {
// Cap idle connections retained for reuse; never exceed the pool's
// hard open cap (MaxOpen).
maxIdle := maxOpen
if maxIdle > defaultMaxIdleConnections {
maxIdle = defaultMaxIdleConnections
}
poolCfg := infinity.DefaultConnectionPoolConfig(uri)
// Don't pre-open connections at process startup (InitialSize=0);
// connections are created lazily on first use. Keep MaxIdle so that
// established connections are retained for reuse instead of being
// reopened on every request.
poolCfg.InitialSize = 0
poolCfg.MaxOpen = maxOpen
poolCfg.MaxIdle = maxIdle
pool, err = infinity.NewConnectionPool(poolCfg, func(u infinity.URI) (*infinity.InfinityConnection, error) {
networkAddress, ok := u.(infinity.NetworkAddress)
if !ok {
return nil, fmt.Errorf("unexpected URI type: %T", u)
}
return infinity.NewInfinityConnectionWithConfig(networkAddress, &thrift.TConfiguration{
ConnectTimeout: server.DefaultConnectTimeout,
SocketTimeout: defaultSocketTimeout,
})
})
if err == nil {
break
}
if i < 23 {
time.Sleep(5 * time.Second)
}
}
if err != nil {
return nil, fmt.Errorf("Failed to connect to Infinity after 120s: %w", err)
}
client := &infinityClient{
pool: pool,
poolMaxOpen: maxOpen,
dbName: cfg.DBName,
hostURI: cfg.URI,
postgresPort: cfg.PostgresPort,
mappingFileName: cfg.MappingFileName,
}
return client, nil
}
// WaitForHealthy blocks until Infinity is healthy or timeout
func (c *infinityClient) WaitForHealthy(ctx context.Context, timeout time.Duration) error {
common.Info("Waiting for Infinity to be healthy")
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
conn, release, err := c.checkoutConn(ctx, "WaitForHealthy")
if err != nil {
time.Sleep(5 * time.Second)
continue
}
res, err := conn.ShowCurrentNode()
release()
if err != nil {
time.Sleep(5 * time.Second)
continue
}
// Use reflection to access ErrorCode and ServerStatus fields
// since ShowCurrentNodeResponse is in an internal package
v := reflect.ValueOf(res)
if v.Kind() != reflect.Ptr {
common.Warn("Infinity health check returned unexpected response kind",
zap.String("type", fmt.Sprintf("%T", res)),
zap.String("kind", v.Kind().String()),
)
time.Sleep(5 * time.Second)
continue
}
v = v.Elem()
errorCode := v.FieldByName("ErrorCode")
serverStatus := v.FieldByName("ServerStatus")
if !errorCode.IsValid() || !serverStatus.IsValid() {
common.Warn("Infinity health check response missing expected fields",
zap.String("type", fmt.Sprintf("%T", res)),
zap.Bool("has_error_code", errorCode.IsValid()),
zap.Bool("has_server_status", serverStatus.IsValid()),
)
time.Sleep(5 * time.Second)
continue
}
// ErrorCode 0 means OK, ServerStatus "started" or "alive" means healthy
if errorCode.Int() == 0 {
status := serverStatus.String()
if status == "started" || status == "alive" {
common.Info("Infinity is healthy")
return nil
}
}
time.Sleep(5 * time.Second)
}
return fmt.Errorf("Infinity not healthy after %v", timeout)
}
func (c *infinityClient) checkoutConn(ctx context.Context, caller string) (*infinity.InfinityConnection, func(), error) {
if c == nil || c.pool == nil {
return nil, nil, fmt.Errorf("Infinity client not initialized")
}
ctx, cancel := ensureDeadline(ctx, defaultOperationTimeout)
conn, err := c.pool.GetContext(ctx)
if err != nil {
cancel()
if (errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled)) && c.poolMaxOpen > 0 {
stats := c.pool.Stats()
if stats.TotalConnections >= c.poolMaxOpen && stats.AvailableConnections == 0 {
return nil, nil, &poolExhaustedError{caller: caller}
}
}
return nil, nil, err
}
released := false
release := func() {
if released {
return
}
released = true
defer cancel()
if err := c.pool.Put(conn); err != nil {
common.Warn("Infinity connection release failed",
zap.String("caller", caller),
zap.String("conn_ptr", fmt.Sprintf("%p", conn)),
zap.Error(err),
)
}
}
return conn, release, nil
}
func (c *infinityClient) checkoutDatabase(ctx context.Context, caller string) (*infinity.Database, func(), error) {
conn, release, err := c.checkoutConn(ctx, caller)
if err != nil {
return nil, nil, err
}
reqID := c.getDatabaseSeq.Add(1)
inflight := c.getDatabaseInflight.Add(1)
start := time.Now()
stats := c.pool.Stats()
common.Info("Infinity GetDatabase begin",
zap.Uint64("req_id", reqID),
zap.String("caller", caller),
zap.String("db_name", c.dbName),
zap.Bool("is_connected", conn.IsConnected()),
zap.Int64("inflight", inflight),
zap.String("conn_ptr", fmt.Sprintf("%p", conn)),
zap.Int("pool_in_use", stats.InUseConnections),
zap.Int("pool_available", stats.AvailableConnections),
)
db, err := conn.GetDatabase(c.dbName)
if err != nil {
inflight = c.getDatabaseInflight.Add(-1)
elapsed := time.Since(start)
common.Warn("Infinity GetDatabase failed",
zap.Uint64("req_id", reqID),
zap.String("caller", caller),
zap.String("db_name", c.dbName),
zap.Bool("is_connected", conn.IsConnected()),
zap.Int64("inflight", inflight),
zap.Duration("elapsed", elapsed),
zap.Error(err),
)
release()
return nil, nil, err
}
wrappedRelease := func() {
inflight := c.getDatabaseInflight.Add(-1)
elapsed := time.Since(start)
common.Info("Infinity GetDatabase done",
zap.Uint64("req_id", reqID),
zap.String("caller", caller),
zap.String("db_name", c.dbName),
zap.Bool("is_connected", conn.IsConnected()),
zap.Int64("inflight", inflight),
zap.Duration("elapsed", elapsed),
)
release()
}
return db, wrappedRelease, nil
}
// Engine Infinity engine implementation using Go SDK
type infinityEngine struct {
config *server.InfinityConfig
client *infinityClient
mappingFileName string
docMetaMappingFileName string
}
// NewEngine creates an Infinity engine
func NewEngine(cfg interface{}) (*infinityEngine, error) {
if cfg == nil {
return nil, fmt.Errorf("infinity config is nil, please check your configuration file for 'doc_engine.infinity' settings")
}
infConfig, ok := cfg.(*server.InfinityConfig)
if !ok {
return nil, fmt.Errorf("invalid infinity config type, expected *config.InfinityConfig")
}
if infConfig == nil {
return nil, fmt.Errorf("infinity config is nil, please check your configuration file for 'doc_engine.infinity' settings")
}
client, err := NewInfinityClient(infConfig)
if err != nil {
return nil, err
}
mappingFileName := infConfig.MappingFileName
if mappingFileName == "" {
mappingFileName = "infinity_mapping.json"
}
docMetaMappingFileName := infConfig.DocMetaMappingFileName
if docMetaMappingFileName == "" {
docMetaMappingFileName = "doc_meta_infinity_mapping.json"
}
engine := &infinityEngine{
config: infConfig,
client: client,
mappingFileName: mappingFileName,
docMetaMappingFileName: docMetaMappingFileName,
}
// Wait for Infinity to be healthy
if err := client.WaitForHealthy(context.Background(), 120*time.Second); err != nil {
return nil, fmt.Errorf("Infinity not healthy: %w", err)
}
// MigrateDB creates the database if it doesn't exist
if err := engine.MigrateDB(context.Background()); err != nil {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
return engine, nil
}
// GetType returns the engine type
func (e *infinityEngine) GetType() string {
return "infinity"
}
// SupportsPageRank returns false because Infinity does not support pagerank.
func (e *infinityEngine) SupportsPageRank() bool {
return false
}
// Ping checks if Infinity is accessible
func (e *infinityEngine) Ping(ctx context.Context) error {
if e.client == nil || e.client.pool == nil {
return fmt.Errorf("Infinity client not initialized")
}
conn, release, err := e.client.checkoutConn(ctx, "Ping")
if err != nil {
return err
}
defer release()
if !conn.IsConnected() {
return fmt.Errorf("Infinity not connected")
}
return nil
}
// Close closes the Infinity connection
func (e *infinityEngine) Close() error {
if e.client != nil && e.client.pool != nil {
return e.client.pool.Close()
}
return nil
}
// MigrateDB creates the database if it doesn't exist
func (e *infinityEngine) MigrateDB(ctx context.Context) error {
conn, release, err := e.client.checkoutConn(ctx, "MigrateDB")
if err != nil {
return fmt.Errorf("failed to get connection: %w", err)
}
defer release()
_, err = conn.CreateDatabase(e.client.dbName, infinity.ConflictTypeIgnore, "")
if err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
return nil
}