🐛 fix(connection/elasticsearch): 修复索引加载超时与测试按钮卡顿

- 使用 CAT Indices 轻量枚举索引并兼容 ES 7.3 参数
- CAT 失败时在统一超时预算内回退 Alias API
- 分离连接测试与保存状态并后台加载数据库列表
- 隔离过期测试请求,避免弹窗重开后状态污染
- 补充索引回退、超时预算与按钮状态回归测试
This commit is contained in:
Syngnat
2026-07-21 18:41:58 +08:00
parent 446707698f
commit 3c8692b133
5 changed files with 524 additions and 117 deletions

View File

@@ -24,6 +24,7 @@ const notifyStoreSubscribers = () => {
};
let mockFormValues: Record<string, any> = {};
let mockValidateFields: (() => Promise<void>) | undefined;
const antdMessage = vi.hoisted(() => ({
error: vi.fn(),
@@ -223,7 +224,7 @@ vi.mock("antd", () => {
const Switch = () => <button type="button">switch</button>;
const formApi = {
validateFields: vi.fn(() => Promise.resolve()),
validateFields: vi.fn(() => mockValidateFields?.() ?? Promise.resolve()),
getFieldsValue: vi.fn(() => ({
type: "mysql",
timeout: 30,
@@ -363,6 +364,7 @@ describe("ConnectionModal i18n", () => {
storeState.updateConnection.mockReset();
storeState.setLanguagePreference.mockClear();
mockFormValues = {};
mockValidateFields = undefined;
setCurrentLanguage("zh-CN");
});
@@ -1030,6 +1032,7 @@ describe("ConnectionModal i18n", () => {
it("retranslates test failure feedback while preserving raw detail when language changes in-place", async () => {
storeState.appearance.uiVersion = "legacy";
setCurrentLanguage("zh-CN");
backendApp.TestConnection.mockReset();
backendApp.TestConnection.mockResolvedValue({
success: false,
message: "backend raw error: /tmp/app.db",
@@ -1067,6 +1070,140 @@ describe("ConnectionModal i18n", () => {
expect(pageText).toContain("backend raw error: /tmp/app.db");
});
it("stops connection action loading before optional database discovery finishes", async () => {
storeState.appearance.uiVersion = "legacy";
setCurrentLanguage("zh-CN");
backendApp.TestConnection.mockResolvedValue({
success: true,
message: "连接正常",
});
let resolveDatabases: ((value: { success: true; data: unknown[] }) => void) | undefined;
backendApp.DBGetDatabases.mockReset();
backendApp.DBGetDatabases.mockReturnValue(
new Promise((resolve) => {
resolveDatabases = resolve;
}),
);
const { default: ConnectionModal } = await import("./ConnectionModal");
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<ConnectionModal
open
onClose={vi.fn()}
initialValues={initialConnection("elasticsearch", { port: 9200 })}
/>,
);
});
await act(async () => {
findButton(renderer!, "测试连接").props.onClick();
await flushConnectionTestTick();
});
expect(textContent(renderer!.toJSON())).toContain("连接成功");
expect(findButton(renderer!, "测试连接").props.disabled).toBe(false);
expect(findButton(renderer!, "保存").props.disabled).toBe(false);
expect(backendApp.DBGetDatabases).toHaveBeenCalledTimes(1);
await act(async () => {
resolveDatabases?.({ success: true, data: [] });
await flushConnectionTestTick();
});
});
it("does not let a stale validation run restart loading after the modal reopens", async () => {
storeState.appearance.uiVersion = "legacy";
setCurrentLanguage("zh-CN");
let resolveValidation: (() => void) | undefined;
mockValidateFields = () =>
new Promise<void>((resolve) => {
resolveValidation = resolve;
});
backendApp.TestConnection.mockReset();
backendApp.TestConnection.mockRejectedValue(new Error("stale validation failure"));
const { default: ConnectionModal } = await import("./ConnectionModal");
const connection = initialConnection("elasticsearch", { port: 9200 });
const onClose = vi.fn();
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<ConnectionModal open onClose={onClose} initialValues={connection} />,
);
});
await act(async () => {
findButton(renderer!, "测试连接").props.onClick();
await flushConnectionTestTick();
});
await act(async () => {
renderer!.update(
<ConnectionModal open={false} onClose={onClose} initialValues={connection} />,
);
});
await act(async () => {
renderer!.update(
<ConnectionModal open onClose={onClose} initialValues={connection} />,
);
});
await act(async () => {
resolveValidation?.();
await flushConnectionTestTick();
});
expect(backendApp.TestConnection).not.toHaveBeenCalled();
expect(textContent(renderer!.toJSON())).not.toContain("stale validation failure");
expect(findButton(renderer!, "测试连接").props.disabled).toBe(false);
expect(findButton(renderer!, "保存").props.disabled).toBe(false);
});
it("ignores a stale connection-test rejection after the modal reopens", async () => {
storeState.appearance.uiVersion = "legacy";
setCurrentLanguage("zh-CN");
let rejectConnection: ((reason?: unknown) => void) | undefined;
backendApp.TestConnection.mockReset();
backendApp.TestConnection.mockReturnValue(
new Promise((_, reject) => {
rejectConnection = reject;
}),
);
const { default: ConnectionModal } = await import("./ConnectionModal");
const connection = initialConnection("elasticsearch", { port: 9200 });
const onClose = vi.fn();
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<ConnectionModal open onClose={onClose} initialValues={connection} />,
);
});
await act(async () => {
findButton(renderer!, "测试连接").props.onClick();
await flushConnectionTestTick();
});
expect(backendApp.TestConnection).toHaveBeenCalledTimes(1);
await act(async () => {
renderer!.update(
<ConnectionModal open={false} onClose={onClose} initialValues={connection} />,
);
});
await act(async () => {
renderer!.update(
<ConnectionModal open onClose={onClose} initialValues={connection} />,
);
});
await act(async () => {
rejectConnection?.(new Error("stale connection failure"));
await flushConnectionTestTick();
});
expect(textContent(renderer!.toJSON())).not.toContain("stale connection failure");
expect(findButton(renderer!, "测试连接").props.disabled).toBe(false);
expect(findButton(renderer!, "保存").props.disabled).toBe(false);
});
it("renders English data source groups and hints for the remaining step one copy", async () => {
storeState.appearance.uiVersion = "legacy";
setCurrentLanguage("en-US");

View File

@@ -328,7 +328,8 @@ const ConnectionModal: React.FC<{
onSaved?: (savedConnection: SavedConnection) => void | Promise<void>;
}> = ({ open, onClose, initialValues, onOpenDriverManager, onSaved }) => {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [useSSL, setUseSSL] = useState(false);
const [useSSH, setUseSSH] = useState(false);
const [useProxy, setUseProxy] = useState(false);
@@ -374,6 +375,7 @@ const ConnectionModal: React.FC<{
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(null);
const testRunIdRef = useRef(0);
const addConnection = useStore((state) => state.addConnection);
const updateConnection = useStore((state) => state.updateConnection);
const theme = useStore((state) => state.theme);
@@ -1325,8 +1327,10 @@ const ConnectionModal: React.FC<{
};
useEffect(() => {
testRunIdRef.current += 1;
if (open) {
setLoading(false);
setSaving(false);
setTestingConnection(false);
testInFlightRef.current = false;
if (testTimerRef.current !== null) {
window.clearTimeout(testTimerRef.current);
@@ -1664,6 +1668,7 @@ const ConnectionModal: React.FC<{
useEffect(() => {
return () => {
testRunIdRef.current += 1;
if (testTimerRef.current !== null) {
window.clearTimeout(testTimerRef.current);
testTimerRef.current = null;
@@ -1687,7 +1692,7 @@ const ConnectionModal: React.FC<{
);
return;
}
setLoading(true);
setSaving(true);
const config = await buildConnectionConfig({
values,
@@ -1745,12 +1750,12 @@ const ConnectionModal: React.FC<{
),
);
} finally {
setLoading(false);
setSaving(false);
}
};
const requestTest = () => {
if (loading) return;
if (saving || testingConnection) return;
if (testTimerRef.current !== null) return;
testTimerRef.current = window.setTimeout(() => {
testTimerRef.current = null;
@@ -1802,13 +1807,17 @@ const ConnectionModal: React.FC<{
const handleTest = async () => {
if (testInFlightRef.current) return;
testInFlightRef.current = true;
const testRunId = ++testRunIdRef.current;
const isCurrentTestRun = () => testRunIdRef.current === testRunId;
try {
await form.validateFields();
if (!isCurrentTestRun()) return;
const values = form.getFieldsValue(true);
const unavailableReason = await resolveDriverUnavailableReason(
values.type,
values.driver,
);
if (!isCurrentTestRun()) return;
if (unavailableReason) {
applyTestFailureFeedback({
kind: "driver_unavailable",
@@ -1835,7 +1844,7 @@ const ConnectionModal: React.FC<{
});
return;
}
setLoading(true);
setTestingConnection(true);
setTestResult(null);
const config = await buildConnectionConfig({
values,
@@ -1843,6 +1852,7 @@ const ConnectionModal: React.FC<{
initialValues,
translate: t,
});
if (!isCurrentTestRun()) return;
if (initialValues?.id) {
config.id = initialValues.id;
}
@@ -1868,79 +1878,100 @@ const ConnectionModal: React.FC<{
t("connection.modal.test.timeout", { seconds: timeoutSeconds }),
);
if (!isCurrentTestRun()) return;
if (res.success) {
void message.destroy("connection-test-failure");
setTestResult({ type: "success", message: res.message });
if (isRedisType) {
const dbRes = await withClientTimeout(
RedisGetDatabases(config as any),
rpcTimeoutMs,
t("connection.modal.test.redis_database_list_timeout", {
seconds: timeoutSeconds,
}),
);
if (dbRes.success) {
const supportedDbs = extractRedisDatabaseList(dbRes.data);
setRedisDbList(supportedDbs);
form.setFieldValue(
"includeRedisDatabases",
normalizeRedisDatabaseSelection(
form.getFieldValue("includeRedisDatabases"),
supportedDbs,
),
);
} else {
setRedisDbList(
buildRedisDatabaseList(
config.redisDB,
form.getFieldValue("includeRedisDatabases"),
),
);
message.warning(
t("connection.modal.test.redis_database_list_failure", {
detail: normalizeConnectionSecretErrorMessage(
dbRes.message,
t("connection.modal.error.unknown"),
),
}),
);
}
} else if (!isJVMType) {
// Other databases: fetch database list
const dbRes = await withClientTimeout(
DBGetDatabases(dbTestConfig as any),
rpcTimeoutMs,
t("connection.modal.test.databaseListTimeout", {
seconds: timeoutSeconds,
}),
);
if (dbRes.success) {
const dbRows = Array.isArray(dbRes.data) ? dbRes.data : [];
const dbs = dbRows
.map((row: any) => row?.Database || row?.database)
.filter(
(name: any) => typeof name === "string" && name.trim() !== "",
void (async () => {
try {
if (isRedisType) {
const dbRes = await withClientTimeout(
RedisGetDatabases(config as any),
rpcTimeoutMs,
t("connection.modal.test.redis_database_list_timeout", {
seconds: timeoutSeconds,
}),
);
setDbList(dbs);
if (dbs.length === 0) {
message.warning(
values.type === "dameng"
? t("connection.modal.test.noVisibleSchema")
: t("connection.modal.test.noVisibleDatabaseList"),
if (!isCurrentTestRun()) return;
if (dbRes.success) {
const supportedDbs = extractRedisDatabaseList(dbRes.data);
setRedisDbList(supportedDbs);
form.setFieldValue(
"includeRedisDatabases",
normalizeRedisDatabaseSelection(
form.getFieldValue("includeRedisDatabases"),
supportedDbs,
),
);
} else {
setRedisDbList(
buildRedisDatabaseList(
config.redisDB,
form.getFieldValue("includeRedisDatabases"),
),
);
message.warning(
t("connection.modal.test.redis_database_list_failure", {
detail: normalizeConnectionSecretErrorMessage(
dbRes.message,
t("connection.modal.error.unknown"),
),
}),
);
}
} else if (!isJVMType) {
const dbRes = await withClientTimeout(
DBGetDatabases(dbTestConfig as any),
rpcTimeoutMs,
t("connection.modal.test.databaseListTimeout", {
seconds: timeoutSeconds,
}),
);
if (!isCurrentTestRun()) return;
if (dbRes.success) {
const dbRows = Array.isArray(dbRes.data) ? dbRes.data : [];
const dbs = dbRows
.map((row: any) => row?.Database || row?.database)
.filter(
(name: any) =>
typeof name === "string" && name.trim() !== "",
);
setDbList(dbs);
if (dbs.length === 0) {
message.warning(
values.type === "dameng"
? t("connection.modal.test.noVisibleSchema")
: t("connection.modal.test.noVisibleDatabaseList"),
);
}
} else {
setDbList([]);
message.warning(
t("connection.modal.test.databaseListFailure", {
detail: normalizeConnectionSecretErrorMessage(
dbRes.message,
t("connection.modal.error.unknown"),
),
}),
);
}
}
} else {
setDbList([]);
} catch (error: unknown) {
if (!isCurrentTestRun()) return;
const detail = normalizeConnectionSecretErrorMessage(
error instanceof Error ? error.message : String(error),
t("connection.modal.error.unknown"),
);
message.warning(
t("connection.modal.test.databaseListFailure", {
detail: normalizeConnectionSecretErrorMessage(
dbRes.message,
t("connection.modal.error.unknown"),
),
}),
isRedisType
? t("connection.modal.test.redis_database_list_failure", {
detail,
})
: t("connection.modal.test.databaseListFailure", { detail }),
);
}
}
})();
} else {
applyTestFailureFeedback({
kind: "runtime",
@@ -1949,6 +1980,7 @@ const ConnectionModal: React.FC<{
});
}
} catch (e: unknown) {
if (!isCurrentTestRun()) return;
if (e && typeof e === "object" && "errorFields" in e) {
applyTestFailureFeedback({
kind: "validation",
@@ -1968,8 +2000,10 @@ const ConnectionModal: React.FC<{
fallbackKey: "connection.modal.test.fallback.unknownException",
});
} finally {
testInFlightRef.current = false;
setLoading(false);
if (isCurrentTestRun()) {
testInFlightRef.current = false;
setTestingConnection(false);
}
}
};
@@ -2708,8 +2742,8 @@ const ConnectionModal: React.FC<{
<Space size={8} style={{ flexShrink: 0 }}>
<Button
key="test"
loading={loading}
disabled={operationBlocked}
loading={testingConnection}
disabled={operationBlocked || saving}
onClick={requestTest}
>
{t("connection.action.test")}
@@ -2720,8 +2754,8 @@ const ConnectionModal: React.FC<{
<Button
key="submit"
type="primary"
loading={loading}
disabled={operationBlocked}
loading={saving}
disabled={operationBlocked || testingConnection}
onClick={handleOk}
>
{t("common.action.save")}

View File

@@ -23,7 +23,7 @@ func init() {
"tdengine": "src-779b9b537f08856f",
"iotdb": "src-7edea4aba8d4869e",
"clickhouse": "src-d4150c3fb3d1313a",
"elasticsearch": "src-8086b38d85310b20",
"elasticsearch": "src-3dc1697786483347",
"trino": "src-ba947f211ce7b19f",
}
}

View File

@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -20,19 +21,32 @@ import (
"GoNavi-Wails/internal/ssh"
"github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/esapi"
)
const (
defaultEsPingTimeout = 5 * time.Second
defaultEsQueryTimeout = 30 * time.Second
defaultEsPingTimeout = 5 * time.Second
defaultEsQueryTimeout = 30 * time.Second
defaultEsIndexListTimeout = 10 * time.Second
maxEsCatIndexListTimeout = 4 * time.Second
)
// ElasticsearchDB 实现 Database 接口,提供 Elasticsearch 数据源连接能力。
type ElasticsearchDB struct {
client *elasticsearch.Client
database string // 默认索引名
pingTimeout time.Duration
forwarder *ssh.LocalForwarder
client *elasticsearch.Client
database string // 默认索引名
pingTimeout time.Duration
indexListTimeout time.Duration // 0 表示使用默认索引枚举总超时
forwarder *ssh.LocalForwarder
}
type esHTTPStatusError struct {
statusCode int
status string
}
func (e *esHTTPStatusError) Error() string {
return e.status
}
// Connect 建立到 Elasticsearch 集群的连接。
@@ -327,10 +341,95 @@ func (e *ElasticsearchDB) GetDatabases() ([]string, error) {
return nil, localizedDatabaseRuntimeError("db.backend.error.connection_not_open", nil)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
totalTimeout := e.indexListTimeout
if totalTimeout <= 0 {
totalTimeout = defaultEsIndexListTimeout
}
deadline := time.Now().Add(totalTimeout)
catTimeout := totalTimeout / 2
if catTimeout > maxEsCatIndexListTimeout {
catTimeout = maxEsCatIndexListTimeout
}
if catTimeout <= 0 {
catTimeout = totalTimeout
}
// Alias API 只返回索引名和别名,避免列表加载下载全部 settings/mappings。
catCtx, cancelCat := context.WithTimeout(context.Background(), catTimeout)
indices, catErr := e.getDatabasesViaCat(catCtx)
cancelCat()
if catErr == nil {
return normalizeESIndexNames(indices), nil
}
logger.Warnf("Elasticsearch CAT 索引枚举失败,回退 Alias API%v", catErr)
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, fmt.Errorf("获取索引列表失败CAT Indices API: %vAlias API: 总超时 %s 已耗尽", catErr, totalTimeout)
}
aliasCtx, cancelAlias := context.WithTimeout(context.Background(), remaining)
indices, aliasErr := e.getDatabasesViaAlias(aliasCtx)
cancelAlias()
if aliasErr == nil {
return normalizeESIndexNames(indices), nil
}
return nil, fmt.Errorf("获取索引列表失败CAT Indices API: %vAlias API: %v", catErr, aliasErr)
}
func (e *ElasticsearchDB) getDatabasesViaCat(ctx context.Context) ([]string, error) {
indices, err := e.getDatabasesViaCatRequest(ctx, true)
if err == nil {
return indices, nil
}
var statusErr *esHTTPStatusError
if !errors.As(err, &statusErr) || statusErr.statusCode != http.StatusBadRequest {
return nil, err
}
indices, compatibilityErr := e.getDatabasesViaCatRequest(ctx, false)
if compatibilityErr != nil {
return nil, fmt.Errorf("全量通配请求: %v旧版兼容请求: %v", err, compatibilityErr)
}
return indices, nil
}
func (e *ElasticsearchDB) getDatabasesViaCatRequest(ctx context.Context, expandAll bool) ([]string, error) {
options := []func(*esapi.CatIndicesRequest){
e.client.Cat.Indices.WithContext(ctx),
e.client.Cat.Indices.WithFormat("json"),
e.client.Cat.Indices.WithH("index"),
}
if expandAll {
options = append(options, e.client.Cat.Indices.WithExpandWildcards("all"))
}
res, err := e.client.Cat.Indices(options...)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.IsError() {
_, _ = io.Copy(io.Discard, res.Body)
return nil, &esHTTPStatusError{statusCode: res.StatusCode, status: res.Status()}
}
var rows []struct {
Index string `json:"index"`
}
if err := json.NewDecoder(res.Body).Decode(&rows); err != nil {
return nil, fmt.Errorf("解析响应失败:%w", err)
}
indices := make([]string, 0, len(rows))
for _, row := range rows {
indices = append(indices, row.Index)
}
return indices, nil
}
func (e *ElasticsearchDB) getDatabasesViaAlias(ctx context.Context) ([]string, error) {
res, err := e.client.Indices.GetAlias(
e.client.Indices.GetAlias.WithContext(ctx),
e.client.Indices.GetAlias.WithIndex("*"),
@@ -339,26 +438,43 @@ func (e *ElasticsearchDB) GetDatabases() ([]string, error) {
e.client.Indices.GetAlias.WithIgnoreUnavailable(true),
)
if err != nil {
return nil, fmt.Errorf("获取索引列表失败:%w", err)
return nil, err
}
defer res.Body.Close()
if res.IsError() {
return nil, fmt.Errorf("获取索引列表失败:%s", res.Status())
_, _ = io.Copy(io.Discard, res.Body)
return nil, &esHTTPStatusError{statusCode: res.StatusCode, status: res.Status()}
}
var indexMap map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&indexMap); err != nil {
return nil, fmt.Errorf("解析索引列表失败:%w", err)
return nil, fmt.Errorf("解析响应失败:%w", err)
}
result := make([]string, 0, len(indexMap))
indices := make([]string, 0, len(indexMap))
for name := range indexMap {
if name := strings.TrimSpace(name); name != "" {
result = append(result, name)
}
indices = append(indices, name)
}
return result, nil
return indices, nil
}
func normalizeESIndexNames(indices []string) []string {
seen := make(map[string]struct{}, len(indices))
result := make([]string, 0, len(indices))
for _, index := range indices {
name := strings.TrimSpace(index)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
result = append(result, name)
}
sort.Strings(result)
return result
}
// GetTables 对 ES 而言索引即表,返回索引自身名称及别名。

View File

@@ -11,6 +11,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
"GoNavi-Wails/internal/connection"
@@ -174,22 +175,28 @@ func TestElasticsearchConnectRejectsFailedPing(t *testing.T) {
// TestElasticsearchGetDatabases 测试获取索引列表。
func TestElasticsearchGetDatabases(t *testing.T) {
t.Run("使用轻量别名端点获取全部索引", func(t *testing.T) {
var fullIndexDefinitionsRequested atomic.Bool
t.Run("现代版本使用 CAT 全量通配获取全部索引", func(t *testing.T) {
var aliasListingRequested atomic.Bool
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/*/_alias" {
writeJSON(w, map[string]interface{}{
"logs-2024": map[string]interface{}{"aliases": map[string]interface{}{}},
"users": map[string]interface{}{"aliases": map[string]interface{}{}},
".security": map[string]interface{}{"aliases": map[string]interface{}{}},
".kibana_1": map[string]interface{}{"aliases": map[string]interface{}{}},
"products": map[string]interface{}{"aliases": map[string]interface{}{}},
if r.Method == http.MethodGet && r.URL.Path == "/_cat/indices" {
query := r.URL.Query()
if query.Get("format") != "json" || query.Get("h") != "index" || query.Get("expand_wildcards") != "all" {
w.WriteHeader(http.StatusBadRequest)
return
}
writeJSON(w, []map[string]string{
{"index": "users"},
{"index": ".security"},
{"index": "logs-2024"},
{"index": "users"},
{"index": ".kibana_1"},
{"index": "products"},
})
return
}
if r.Method == http.MethodGet && r.URL.Path == "/*" {
fullIndexDefinitionsRequested.Store(true)
w.WriteHeader(http.StatusForbidden)
if r.Method == http.MethodGet && r.URL.Path == "/*/_alias" {
aliasListingRequested.Store(true)
w.WriteHeader(http.StatusGatewayTimeout)
return
}
w.WriteHeader(http.StatusNotFound)
@@ -201,7 +208,6 @@ func TestElasticsearchGetDatabases(t *testing.T) {
t.Fatalf("GetDatabases 失败:%v", err)
}
slices.Sort(databases)
expected := []string{".kibana_1", ".security", "logs-2024", "products", "users"}
if len(databases) != len(expected) {
t.Fatalf("期望 %d 个索引,实际 %d%v", len(expected), len(databases), databases)
@@ -211,23 +217,59 @@ func TestElasticsearchGetDatabases(t *testing.T) {
t.Fatalf("索引 [%d] 期望 %q实际 %q", i, name, databases[i])
}
}
if fullIndexDefinitionsRequested.Load() {
t.Fatal("GetDatabases 不应请求包含 settings/mappings 的完整索引定义")
if aliasListingRequested.Load() {
t.Fatal("CAT 端点成功时不应继续请求 Alias API")
}
})
t.Run("ES 7.3 拒绝全量通配参数后重试兼容 CAT 请求", func(t *testing.T) {
var catListingAttempts atomic.Int32
var aliasListingRequested atomic.Bool
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/_cat/indices":
catListingAttempts.Add(1)
query := r.URL.Query()
if query.Has("expand_wildcards") {
w.WriteHeader(http.StatusBadRequest)
return
}
if query.Get("format") != "json" || query.Get("h") != "index" {
w.WriteHeader(http.StatusBadRequest)
return
}
writeJSON(w, []map[string]string{{"index": "legacy-events"}})
case r.Method == http.MethodGet && r.URL.Path == "/*/_alias":
aliasListingRequested.Store(true)
w.WriteHeader(http.StatusGatewayTimeout)
default:
w.WriteHeader(http.StatusNotFound)
}
})
db := newTestESDB(t, server.URL, "")
databases, err := db.GetDatabases()
if err != nil {
t.Fatalf("GetDatabases 应兼容 ES 7.3 CAT 参数:%v", err)
}
if attempts := catListingAttempts.Load(); attempts != 2 {
t.Fatalf("期望先尝试现代 CAT 再重试兼容请求,实际请求 %d 次", attempts)
}
if aliasListingRequested.Load() {
t.Fatal("兼容 CAT 请求成功时不应回退 Alias API")
}
if !slices.Equal(databases, []string{"legacy-events"}) {
t.Fatalf("期望 [legacy-events],实际 %v", databases)
}
})
t.Run("允许没有索引的空集群", func(t *testing.T) {
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/*/_alias" {
if r.Method != http.MethodGet || r.URL.Path != "/_cat/indices" {
w.WriteHeader(http.StatusNotFound)
return
}
query := r.URL.Query()
if query.Get("allow_no_indices") != "true" || query.Get("ignore_unavailable") != "true" {
w.WriteHeader(http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{})
writeJSON(w, []map[string]string{})
})
db := newTestESDB(t, server.URL, "")
@@ -240,6 +282,76 @@ func TestElasticsearchGetDatabases(t *testing.T) {
}
})
t.Run("CAT 端点不可用时回退到 Alias API", func(t *testing.T) {
var catListingRequested atomic.Bool
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/_cat/indices":
catListingRequested.Store(true)
w.WriteHeader(http.StatusForbidden)
case r.Method == http.MethodGet && r.URL.Path == "/*/_alias":
query := r.URL.Query()
if query.Get("allow_no_indices") != "true" || query.Get("ignore_unavailable") != "true" {
w.WriteHeader(http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"archive": map[string]interface{}{"aliases": map[string]interface{}{}},
"orders": map[string]interface{}{"aliases": map[string]interface{}{}},
})
default:
w.WriteHeader(http.StatusNotFound)
}
})
db := newTestESDB(t, server.URL, "")
databases, err := db.GetDatabases()
if err != nil {
t.Fatalf("GetDatabases 应在 CAT 被拒绝时回退:%v", err)
}
if !catListingRequested.Load() {
t.Fatal("GetDatabases 应先尝试 CAT 端点")
}
expected := []string{"archive", "orders"}
if !slices.Equal(databases, expected) {
t.Fatalf("期望 %v实际 %v", expected, databases)
}
})
t.Run("CAT 超时后使用剩余预算回退到 Alias API", func(t *testing.T) {
var aliasListingRequested atomic.Bool
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/_cat/indices":
<-r.Context().Done()
case r.Method == http.MethodGet && r.URL.Path == "/*/_alias":
aliasListingRequested.Store(true)
writeJSON(w, map[string]interface{}{
"events": map[string]interface{}{"aliases": map[string]interface{}{}},
})
default:
w.WriteHeader(http.StatusNotFound)
}
})
db := newTestESDB(t, server.URL, "")
db.indexListTimeout = 500 * time.Millisecond
started := time.Now()
databases, err := db.GetDatabases()
if err != nil {
t.Fatalf("GetDatabases 应在 CAT 超时后回退:%v", err)
}
if !aliasListingRequested.Load() {
t.Fatal("CAT 超时后应使用新的上下文请求 Alias API")
}
if !slices.Equal(databases, []string{"events"}) {
t.Fatalf("期望 [events],实际 %v", databases)
}
if elapsed := time.Since(started); elapsed >= db.indexListTimeout {
t.Fatalf("回退不应耗尽总超时预算,实际耗时 %s", elapsed)
}
})
t.Run("连接未打开时返回错误", func(t *testing.T) {
db := &ElasticsearchDB{}
_, err := db.GetDatabases()
@@ -1099,6 +1211,14 @@ func TestESMockIntegration(t *testing.T) {
case r.Method == http.MethodHead && path == "/":
w.WriteHeader(http.StatusOK)
// Cat.Indices — 仅返回索引名,兼容 ES 6/7/8。
case r.Method == http.MethodGet && path == "/_cat/indices":
writeJSON(w, []map[string]string{
{"index": "products"},
{"index": "orders"},
{"index": ".internal"},
})
// 完整索引定义响应可能过大,列表加载不应请求该端点。
case r.Method == http.MethodGet && path == "/*":
w.WriteHeader(http.StatusForbidden)