🐛 fix(query): 放开所有数据源默认查询超时

- 区分连接超时与查询超时,默认查询仅保留手动取消能力
- 清理各数据库及 HTTP 数据源由连接配置派生的请求超时
- 新增按请求 queryTimeout 透传并保留计数任务显式时限
- 补充前后端、缓存键与多驱动超时策略回归测试
This commit is contained in:
Syngnat
2026-08-06 16:50:28 +08:00
parent 0e46b9ff87
commit 32dfd39827
30 changed files with 376 additions and 76 deletions

View File

@@ -467,16 +467,20 @@ func chromaAuthHeaders(config connection.ConnectionConfig) map[string]string {
func buildChromaHTTPClient(config connection.ConnectionConfig) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
dialTimeout := getConnectTimeout(config)
transport.DialContext = (&net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second}).DialContext
if tlsConfig, err := resolveGenericTLSConfig(config); err == nil && tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
}
if config.UseProxy {
proxyCfg := config.Proxy
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return proxytunnel.DialContext(ctx, proxyCfg, network, addr)
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
defer cancel()
return proxytunnel.DialContext(dialCtx, proxyCfg, network, addr)
}
}
return &http.Client{Transport: transport, Timeout: getConnectTimeout(config)}
return &http.Client{Transport: transport}
}
func (c *ChromaDB) detectVersion(ctx context.Context) error {

View File

@@ -32,8 +32,10 @@ const (
defaultClickHousePort = 9000
defaultClickHouseUser = "default"
defaultClickHouseDatabase = "default"
minClickHouseReadTimeout = 5 * time.Minute
clickHouseHTTPPortHint = "8123/8125/8132/8443"
// clickhouse-go replaces a zero ReadTimeout with five minutes. Max duration
// keeps context cancellation as the only practical automatic query deadline.
clickHouseNoAutomaticReadTimeout = time.Duration(1<<63 - 1)
clickHouseHTTPPortHint = "8123/8125/8132/8443"
clickHouseProtocolAuto = "auto"
clickHouseProtocolHTTP = "http"
@@ -179,10 +181,6 @@ func (c *ClickHouseDB) buildClickHouseOptions(config connection.ConnectionConfig
func (c *ClickHouseDB) buildClickHouseOptionsWithHTTPCompatibility(config connection.ConnectionConfig, stripHTTPClientProtocolVersion bool) (*clickhouse.Options, error) {
connectTimeout := getConnectTimeout(config)
readTimeout := connectTimeout
if readTimeout < minClickHouseReadTimeout {
readTimeout = minClickHouseReadTimeout
}
protocol := detectClickHouseProtocol(config)
opts := &clickhouse.Options{
Protocol: protocol,
@@ -195,7 +193,7 @@ func (c *ClickHouseDB) buildClickHouseOptionsWithHTTPCompatibility(config connec
Password: config.Password,
},
DialTimeout: connectTimeout,
ReadTimeout: readTimeout,
ReadTimeout: clickHouseNoAutomaticReadTimeout,
}
tlsConfig, err := resolveGenericTLSConfig(config)
if err != nil {

View File

@@ -642,7 +642,7 @@ func TestClickHouseOptions_UsesStructuredTimeoutAndAuth(t *testing.T) {
if opts.DialTimeout != 15*time.Second {
t.Fatalf("dial timeout 不符合预期:%s", opts.DialTimeout)
}
if opts.ReadTimeout != minClickHouseReadTimeout {
if opts.ReadTimeout != clickHouseNoAutomaticReadTimeout {
t.Fatalf("read timeout 不符合预期:%s", opts.ReadTimeout)
}
if _, ok := opts.Settings["write_timeout"]; ok {
@@ -687,7 +687,7 @@ func TestClickHouseOptions_MergesConnectionParamsIntoOptionsAndSettings(t *testi
}
}
func TestClickHouseOptions_ReadTimeoutUsesLargerConfiguredTimeout(t *testing.T) {
func TestClickHouseOptions_ConnectionTimeoutDoesNotBecomeReadTimeout(t *testing.T) {
c := &ClickHouseDB{}
cfg := normalizeClickHouseConfig(connection.ConnectionConfig{
Type: "clickhouse",
@@ -709,7 +709,7 @@ func TestClickHouseOptions_ReadTimeoutUsesLargerConfiguredTimeout(t *testing.T)
if opts.DialTimeout != 900*time.Second {
t.Fatalf("dial timeout 不符合预期:%s", opts.DialTimeout)
}
if opts.ReadTimeout != 900*time.Second {
if opts.ReadTimeout != clickHouseNoAutomaticReadTimeout {
t.Fatalf("read timeout 不符合预期:%s", opts.ReadTimeout)
}
}

View File

@@ -678,26 +678,23 @@ func buildESClientConfig(config connection.ConnectionConfig) elasticsearch.Confi
}
}
// 代理支持
if config.UseProxy {
transport, ok := cfg.Transport.(*http.Transport)
if !ok {
transport = http.DefaultTransport.(*http.Transport).Clone()
}
proxyCfg := config.Proxy
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return proxytunnel.DialContext(ctx, proxyCfg, network, addr)
}
cfg.Transport = transport
}
// 超时设置
// Keep the connection dial bounded, but let the request context own query
// cancellation. A connection timeout must not become a response deadline.
timeout := getConnectTimeout(config)
if cfg.Transport == nil {
cfg.Transport = http.DefaultTransport.(*http.Transport).Clone()
}
if transport, ok := cfg.Transport.(*http.Transport); ok {
transport.ResponseHeaderTimeout = timeout
transport.DialContext = (&net.Dialer{Timeout: timeout, KeepAlive: 30 * time.Second}).DialContext
if config.UseProxy {
proxyCfg := config.Proxy
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
return proxytunnel.DialContext(dialCtx, proxyCfg, network, addr)
}
}
transport.ResponseHeaderTimeout = 0
}
// 包装 transport注入 X-Elastic-Product 头以兼容 ES 6.x / 7.x 早期版本。

View File

@@ -59,6 +59,29 @@ func newTestESDB(t *testing.T, serverURL, defaultIndex string) *ElasticsearchDB
}
}
func TestBuildESClientConfigSeparatesConnectionAndRequestTimeout(t *testing.T) {
config := buildESClientConfig(connection.ConnectionConfig{
Type: "elasticsearch",
Host: "127.0.0.1",
Port: defaultEsPort,
Timeout: 1,
})
wrapped, ok := config.Transport.(*esProductCheckBypassTransport)
if !ok {
t.Fatalf("expected product-check transport wrapper, got %T", config.Transport)
}
transport, ok := wrapped.inner.(*http.Transport)
if !ok {
t.Fatalf("expected HTTP transport, got %T", wrapped.inner)
}
if transport.ResponseHeaderTimeout != 0 {
t.Fatalf("connection timeout leaked into Elasticsearch response timeout: %s", transport.ResponseHeaderTimeout)
}
if transport.DialContext == nil {
t.Fatal("expected bounded connection dial")
}
}
// buildMockESMappingResponse 构造模拟的 mapping 响应 JSON。
func buildMockESMappingResponse(indexName string, fields map[string]string) map[string]interface{} {
properties := make(map[string]interface{})

View File

@@ -201,8 +201,11 @@ func (i *IoTDBDB) QueryContext(ctx context.Context, query string) ([]map[string]
if text == "" {
return nil, nil, fmt.Errorf("查询语句不能为空")
}
timeoutMs := int64(i.effectiveTimeout().Milliseconds())
ds, err := i.session.Query(ctx, text, &timeoutMs)
var timeoutMs *int64
if remaining := timeoutMsFromContext(ctx); remaining > 0 {
timeoutMs = &remaining
}
ds, err := i.session.Query(ctx, text, timeoutMs)
if err != nil {
return nil, nil, err
}

View File

@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
@@ -16,13 +17,19 @@ import (
)
type fakeIoTDBSession struct {
queryResults map[string][]map[string]interface{}
execs []string
queryResults map[string][]map[string]interface{}
execs []string
queryTimeouts []int64
}
func (f *fakeIoTDBSession) Close() error { return nil }
func (f *fakeIoTDBSession) Query(_ context.Context, sql string, _ *int64) (iotdbDataSet, error) {
func (f *fakeIoTDBSession) Query(_ context.Context, sql string, timeoutMs *int64) (iotdbDataSet, error) {
timeout := int64(-1)
if timeoutMs != nil {
timeout = *timeoutMs
}
f.queryTimeouts = append(f.queryTimeouts, timeout)
rows := f.queryResults[sql]
return &fakeIoTDBDataSet{rows: rows, columns: fakeIoTDBColumns(rows)}, nil
}
@@ -207,6 +214,32 @@ func TestNormalizeIoTDBValueConvertsBinaryText(t *testing.T) {
}
}
func TestIoTDBQueryContextOnlySendsExplicitDeadlineToServer(t *testing.T) {
session := &fakeIoTDBSession{queryResults: map[string][]map[string]interface{}{
"SELECT * FROM root.sg.d1": {},
}}
client := &IoTDBDB{session: session, pingTimeout: time.Second}
if _, _, err := client.QueryContext(context.Background(), "SELECT * FROM root.sg.d1"); err != nil {
t.Fatalf("QueryContext without deadline: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, _, err := client.QueryContext(ctx, "SELECT * FROM root.sg.d1"); err != nil {
t.Fatalf("QueryContext with deadline: %v", err)
}
if len(session.queryTimeouts) != 2 {
t.Fatalf("query timeout calls = %v", session.queryTimeouts)
}
if session.queryTimeouts[0] != -1 {
t.Fatalf("connection timeout leaked into IoTDB query timeout: %dms", session.queryTimeouts[0])
}
if session.queryTimeouts[1] <= 0 || session.queryTimeouts[1] > 2000 {
t.Fatalf("explicit context deadline was not propagated: %dms", session.queryTimeouts[1])
}
}
func TestIoTDBLiveSmoke(t *testing.T) {
addr := strings.TrimSpace(os.Getenv("GONAVI_IOTDB_TEST_ADDR"))
if addr == "" {

View File

@@ -705,8 +705,10 @@ func newKafkaGoRuntime(config connection.ConnectionConfig) (kafkaRuntime, error)
SASL: mechanism,
}
client := &kafka.Client{
Addr: kafka.TCP(brokers...),
Timeout: timeout,
Addr: kafka.TCP(brokers...),
// Client.Timeout is a per-request deadline. Keep it unset so the
// caller's context (including an explicit queryTimeout) owns it.
Timeout: 0,
Transport: transport,
}
return &kafkaGoRuntime{
@@ -1060,12 +1062,12 @@ type kafkaParsedSQL struct {
}
var (
kafkaSQLFromRE = regexp.MustCompile(`(?i)\bFROM\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([a-zA-Z0-9_.\-]+))`)
kafkaSQLLimitRE = regexp.MustCompile(`(?i)\bLIMIT\s+(\d+)`)
kafkaSQLOffsetRE = regexp.MustCompile(`(?i)\bOFFSET\s+(\d+)`)
kafkaShowTopicsRE = regexp.MustCompile(`(?i)^\s*SHOW\s+TOPICS(?:\s+LIMIT\s+(\d+))?\s*$`)
kafkaDescribeTopicRE = regexp.MustCompile(`(?i)^\s*(?:SHOW|DESCRIBE)\s+TOPIC\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([a-zA-Z0-9_.\-]+))\s*$`)
kafkaConsumeTopicRE = regexp.MustCompile(`(?i)^\s*CONSUME(?:\s+GROUP\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([a-zA-Z0-9_.\-]+)))?\s+FROM\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([a-zA-Z0-9_.\-]+))`)
kafkaSQLFromRE = regexp.MustCompile(`(?i)\bFROM\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([a-zA-Z0-9_.\-]+))`)
kafkaSQLLimitRE = regexp.MustCompile(`(?i)\bLIMIT\s+(\d+)`)
kafkaSQLOffsetRE = regexp.MustCompile(`(?i)\bOFFSET\s+(\d+)`)
kafkaShowTopicsRE = regexp.MustCompile(`(?i)^\s*SHOW\s+TOPICS(?:\s+LIMIT\s+(\d+))?\s*$`)
kafkaDescribeTopicRE = regexp.MustCompile(`(?i)^\s*(?:SHOW|DESCRIBE)\s+TOPIC\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([a-zA-Z0-9_.\-]+))\s*$`)
kafkaConsumeTopicRE = regexp.MustCompile(`(?i)^\s*CONSUME(?:\s+GROUP\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([a-zA-Z0-9_.\-]+)))?\s+FROM\s+(?:"([^"]+)"|` + "`" + `([^` + "`" + `]+)` + "`" + `|([a-zA-Z0-9_.\-]+))`)
)
func parseKafkaSQL(sqlText string, defaultLatest bool) (kafkaParsedSQL, bool) {

View File

@@ -5,6 +5,7 @@ import (
"reflect"
"strings"
"testing"
"time"
"GoNavi-Wails/internal/connection"
@@ -12,13 +13,37 @@ import (
)
type fakeKafkaRuntime struct {
listTopicsResult []kafkaTopicInfo
describeResult kafkaTopicDescription
fetchResult []kafkaMessageRecord
publishAffected int64
lastDescribeTopic string
lastFetchRequest kafkaFetchRequest
lastPublishCommand kafkaPublishCommand
listTopicsResult []kafkaTopicInfo
describeResult kafkaTopicDescription
fetchResult []kafkaMessageRecord
publishAffected int64
lastDescribeTopic string
lastFetchRequest kafkaFetchRequest
lastPublishCommand kafkaPublishCommand
}
func TestKafkaRuntimeDoesNotDeriveRequestTimeoutFromConnectionTimeout(t *testing.T) {
runtime, err := newKafkaGoRuntime(connection.ConnectionConfig{
Type: "kafka",
Host: "127.0.0.1",
Port: 9092,
Timeout: 1,
})
if err != nil {
t.Fatalf("newKafkaGoRuntime: %v", err)
}
defer runtime.Close()
concrete, ok := runtime.(*kafkaGoRuntime)
if !ok {
t.Fatalf("runtime type = %T", runtime)
}
if concrete.client.Timeout != 0 {
t.Fatalf("connection timeout leaked into Kafka request timeout: %s", concrete.client.Timeout)
}
if concrete.dialer.Timeout != time.Second {
t.Fatalf("Kafka dial timeout = %s, want 1s", concrete.dialer.Timeout)
}
}
type kafkaOffsetSeekerRecorder struct {

View File

@@ -515,16 +515,20 @@ func milvusAuthHeaders(config connection.ConnectionConfig) map[string]string {
func buildMilvusHTTPClient(config connection.ConnectionConfig) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
dialTimeout := getConnectTimeout(config)
transport.DialContext = (&net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second}).DialContext
if tlsConfig, err := resolveGenericTLSConfig(config); err == nil && tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
}
if config.UseProxy {
proxyConfig := config.Proxy
transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
return proxytunnel.DialContext(ctx, proxyConfig, network, address)
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
defer cancel()
return proxytunnel.DialContext(dialCtx, proxyConfig, network, address)
}
}
return &http.Client{Transport: transport, Timeout: getConnectTimeout(config)}
return &http.Client{Transport: transport}
}
func (m *MilvusDB) doJSON(ctx context.Context, method, path string, body interface{}, out interface{}) error {

View File

@@ -461,16 +461,20 @@ func qdrantAuthHeaders(config connection.ConnectionConfig) map[string]string {
func buildQdrantHTTPClient(config connection.ConnectionConfig) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
dialTimeout := getConnectTimeout(config)
transport.DialContext = (&net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second}).DialContext
if tlsConfig, err := resolveGenericTLSConfig(config); err == nil && tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
}
if config.UseProxy {
proxyCfg := config.Proxy
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return proxytunnel.DialContext(ctx, proxyCfg, network, addr)
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
defer cancel()
return proxytunnel.DialContext(dialCtx, proxyCfg, network, addr)
}
}
return &http.Client{Transport: transport, Timeout: getConnectTimeout(config)}
return &http.Client{Transport: transport}
}
func (q *QdrantDB) doJSON(ctx context.Context, method, path string, body interface{}, out interface{}) error {

View File

@@ -613,16 +613,20 @@ func buildRabbitMQBaseURL(config connection.ConnectionConfig) string {
func buildRabbitMQHTTPClient(config connection.ConnectionConfig) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
dialTimeout := getConnectTimeout(config)
transport.DialContext = (&net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second}).DialContext
if tlsConfig, err := resolveGenericTLSConfig(config); err == nil && tlsConfig != nil {
transport.TLSClientConfig = tlsConfig
}
if config.UseProxy {
proxyCfg := config.Proxy
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return proxytunnel.DialContext(ctx, proxyCfg, network, addr)
dialCtx, cancel := context.WithTimeout(ctx, dialTimeout)
defer cancel()
return proxytunnel.DialContext(dialCtx, proxyCfg, network, addr)
}
}
return &http.Client{Transport: transport, Timeout: getConnectTimeout(config)}
return &http.Client{Transport: transport}
}
func rabbitmqAuthHeaders(config connection.ConnectionConfig) map[string]string {

View File

@@ -0,0 +1,34 @@
package db
import (
"net/http"
"testing"
"GoNavi-Wails/internal/connection"
)
func TestHTTPDataSourceClientsDoNotUseConnectTimeoutAsRequestTimeout(t *testing.T) {
config := connection.ConnectionConfig{Timeout: 1}
tests := []struct {
name string
build func(connection.ConnectionConfig) *http.Client
}{
{name: "chroma", build: buildChromaHTTPClient},
{name: "qdrant", build: buildQdrantHTTPClient},
{name: "milvus", build: buildMilvusHTTPClient},
{name: "rabbitmq", build: buildRabbitMQHTTPClient},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := tt.build(config)
if client.Timeout != 0 {
t.Fatalf("connection timeout leaked into HTTP request timeout: %s", client.Timeout)
}
transport, ok := client.Transport.(*http.Transport)
if !ok || transport.DialContext == nil {
t.Fatal("expected HTTP transport to retain a bounded connection dial")
}
})
}
}

View File

@@ -261,6 +261,9 @@ func buildTrinoDSN(config connection.ConnectionConfig, customClientName string)
}
params := connectionParamsFromText(config.ConnectionParams)
if params == nil {
params = url.Values{}
}
catalog, schema := resolveTrinoNamespace(config.Database, "")
if catalog != "" {
params.Set("catalog", catalog)
@@ -274,9 +277,8 @@ func buildTrinoDSN(config connection.ConnectionConfig, customClientName string)
if strings.TrimSpace(params.Get("explicitPrepare")) == "" {
params.Set("explicitPrepare", "false")
}
if strings.TrimSpace(params.Get("query_timeout")) == "" {
params.Set("query_timeout", fmt.Sprintf("%ds", getConnectTimeoutSeconds(config)))
}
// Do not derive Trino's server-side query_timeout from the connection
// timeout. The request context is the sole automatic query deadline.
if strings.TrimSpace(customClientName) != "" {
params.Set("custom_client", strings.TrimSpace(customClientName))
}

View File

@@ -6,8 +6,11 @@ import (
"database/sql"
"database/sql/driver"
"errors"
"net/url"
"sync"
"testing"
"GoNavi-Wails/internal/connection"
)
var (
@@ -63,3 +66,24 @@ func TestTrinoCloseCleansStateWhenDatabaseCloseFails(t *testing.T) {
t.Fatalf("Close() namespace = %q, want empty", trino.namespace)
}
}
func TestBuildTrinoDSNDoesNotDeriveQueryTimeoutFromConnectionTimeout(t *testing.T) {
dsn, err := buildTrinoDSN(connection.ConnectionConfig{
Type: "trino",
Host: "127.0.0.1",
Port: 8080,
User: "alice",
Database: "hive.analytics",
Timeout: 1,
}, "")
if err != nil {
t.Fatalf("buildTrinoDSN: %v", err)
}
parsed, err := url.Parse(dsn)
if err != nil {
t.Fatalf("parse Trino DSN: %v", err)
}
if got := parsed.Query().Get("query_timeout"); got != "" {
t.Fatalf("connection timeout leaked into Trino query_timeout=%q", got)
}
}