package doris import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "github.com/Tencent/WeKnora/internal/logger" secutils "github.com/Tencent/WeKnora/internal/utils" ) // Stream Load 相关常量。 const ( // 单批 Stream Load 的 JSON body 上限(保守值,远小于 Doris 默认 streaming_load_max_mb=10240)。 // 主要目的是控制单次 HTTP 请求的尾延迟,超过则自动拆批。 streamLoadMaxBatchBytes = 1 << 20 // 1 MiB // HTTP 头 Authorization 用 Basic auth;Stream Load 也支持 token, // 这里走最常用的用户名/密码方案与 MySQL 协议保持一致。 headerAuthorization = "Authorization" headerExpect = "Expect" headerContentType = "Content-Type" ) // streamLoadResponse 是 Doris FE/BE 返回的 Stream Load 结果体。 // // 关键字段:Status 应为 "Success" 或 "Publish Timeout"(后者表示数据已写入但发布事务超时, // 仍视为成功)。其它状态都视为失败。 type streamLoadResponse struct { TxnId int64 `json:"TxnId"` Label string `json:"Label"` Status string `json:"Status"` Message string `json:"Message"` NumberTotalRows int64 `json:"NumberTotalRows"` NumberLoadedRows int64 `json:"NumberLoadedRows"` NumberFilteredRows int64 `json:"NumberFilteredRows"` NumberUnselectedRows int64 `json:"NumberUnselectedRows"` LoadBytes int64 `json:"LoadBytes"` LoadTimeMs int64 `json:"LoadTimeMs"` BeginTxnTimeMs int64 `json:"BeginTxnTimeMs"` StreamLoadPutTimeMs int64 `json:"StreamLoadPutTimeMs"` ReadDataTimeMs int64 `json:"ReadDataTimeMs"` WriteDataTimeMs int64 `json:"WriteDataTimeMs"` CommitAndPublishTimeMs int64 `json:"CommitAndPublishTimeMs"` ErrorURL string `json:"ErrorURL"` } // streamLoadURL 拼装某张表的 Stream Load HTTP 端点。 func (r *dorisRepository) streamLoadURL(table string) string { return fmt.Sprintf("%s/api/%s/%s/_stream_load", r.feHTTPBase, url.PathEscape(r.database), url.PathEscape(table)) } // newDorisStreamLoadHTTPClient protects both the initial FE request and every // FE -> BE redirect at connection time. Doris requires Basic auth to survive // the 307 redirect, so redirect targets on another host must be explicitly // trusted through the SSRF whitelist before credentials are forwarded. func newDorisStreamLoadHTTPClient() *http.Client { cfg := secutils.DefaultSSRFSafeHTTPClientConfig() // Stream Load calls carry their own context deadline. Preserve the previous // client behaviour instead of imposing the generic 30-second HTTP timeout. cfg.Timeout = 0 client := secutils.NewSSRFSafeHTTPClient(cfg) ssrfCheckRedirect := client.CheckRedirect client.CheckRedirect = func(req *http.Request, via []*http.Request) error { // Keep the shared client's redirect limit, scheme/target validation and // sensitive-header stripping as the authoritative SSRF behaviour. if err := ssrfCheckRedirect(req, via); err != nil { return err } if len(via) == 0 { return nil } sourceHost := via[len(via)-1].URL.Hostname() targetHost := req.URL.Hostname() if !strings.EqualFold(sourceHost, targetHost) && !secutils.IsSSRFWhitelisted(targetHost) { return fmt.Errorf( "stream load redirect blocked: target host %q is not trusted to receive credentials", targetHost, ) } // The shared client deliberately strips Authorization on cross-host // redirects. Doris is the exceptional case where an explicitly trusted // BE needs the same Basic credentials after the FE's 307 response. req.Header.Set(headerAuthorization, via[0].Header.Get(headerAuthorization)) return nil } return client } // partialUpdateRows 把若干行通过 Stream Load 的 partial update 模式写回目标表。 // // columns 是参与本次 partial update 的列(必须包含 UNIQUE KEY 列,即 "id")。 // rows 中每一项是一个与 columns 等长的字段值数组。 // // 实现要点: // 1. 用 JSON 数组的 body 形式,header 加 strip_outer_array=true。 // 2. 设置 partial_columns=true、merge_type=APPEND,触发 Doris 的 partial update 模式 // (Doris 4.1 + UNIQUE KEY MoW 表的标准玩法)。 // 3. 按 streamLoadMaxBatchBytes 自动拆批,避免单次过大。 // 4. 处理 307:Doris 的 FE 会 redirect 到 BE,net/http 默认会跟随; // 这里需要保证 GetBody 可重发(已通过 bytes.NewReader 构造 Body 满足)。 func (r *dorisRepository) partialUpdateRows(ctx context.Context, table string, columns []string, rows []map[string]any, ) error { if len(rows) == 0 { return nil } for _, batch := range chunkRows(rows, streamLoadMaxBatchBytes) { if err := r.streamLoadOnce(ctx, table, columns, batch); err != nil { return err } } return nil } // streamLoadOnce 发出一次 Stream Load HTTP 请求。 func (r *dorisRepository) streamLoadOnce(ctx context.Context, table string, columns []string, rows []map[string]any, ) error { log := logger.GetLogger(ctx) if len(rows) == 0 { return nil } body, err := json.Marshal(rows) if err != nil { return fmt.Errorf("marshal stream load body: %w", err) } url := r.streamLoadURL(table) // Validate at the final outbound boundary as well as when user-supplied // vector-store configuration is created. This covers stored/env configs and // keeps the tainted value from reaching http.Client.Do unchecked. if err := secutils.ValidateURLForSSRF(url); err != nil { return fmt.Errorf("stream load URL blocked by SSRF validation: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("build stream load request: %w", err) } // GetBody 让 redirect 时可以重新读 body(FE -> BE 的 307 需要重发 PUT body)。 bodyCopy := append([]byte(nil), body...) req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(bodyCopy)), nil } req.ContentLength = int64(len(body)) auth := base64.StdEncoding.EncodeToString([]byte(r.username + ":" + r.password)) req.Header.Set(headerAuthorization, "Basic "+auth) req.Header.Set(headerExpect, "100-continue") req.Header.Set(headerContentType, "application/json") req.Header.Set("format", "json") req.Header.Set("strip_outer_array", "true") req.Header.Set("partial_columns", "true") req.Header.Set("columns", strings.Join(columns, ",")) req.Header.Set("merge_type", "APPEND") resp, err := r.httpClient.Do(req) if err != nil { return fmt.Errorf("stream load HTTP: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read stream load response: %w", err) } if resp.StatusCode/100 != 2 { return fmt.Errorf("stream load HTTP %d: %s", resp.StatusCode, string(respBody)) } var result streamLoadResponse if err := json.Unmarshal(respBody, &result); err != nil { return fmt.Errorf("decode stream load response: %w (raw=%s)", err, string(respBody)) } switch result.Status { case "Success", "Publish Timeout": log.Infof("[Doris] Stream load %s OK: rows=%d, loaded=%d, label=%s", table, result.NumberTotalRows, result.NumberLoadedRows, result.Label) return nil default: return fmt.Errorf("stream load failed: status=%s msg=%s err_url=%s", result.Status, result.Message, result.ErrorURL) } } // chunkRows 把行按累积 JSON 体大小切分,每段不超过 maxBytes。 // // 注意:JSON 序列化的实际开销大约是 marshal 后的字节数,而单行 marshal // 加上逗号 + 数组括号约等于本估算。这里用粗略估计避免每段都 marshal。 func chunkRows(rows []map[string]any, maxBytes int) [][]map[string]any { if len(rows) == 0 { return nil } var ( out [][]map[string]any curr []map[string]any size int header = 2 // "[" + "]" ) for _, row := range rows { raw, err := json.Marshal(row) if err != nil { // marshal 失败时把这一行单独成段,由上层 streamLoadOnce 再次 marshal 报错。 if len(curr) > 0 { out = append(out, curr) } out = append(out, []map[string]any{row}) curr = nil size = 0 continue } // 加上逗号位(除第一行之外)。 need := len(raw) if len(curr) > 0 { need++ } if size+need+header > maxBytes && len(curr) > 0 { out = append(out, curr) curr = nil size = 0 } curr = append(curr, row) size += need } if len(curr) > 0 { out = append(out, curr) } return out } // --------------------------------------------------------------------------- // 业务面方法:BatchUpdateChunkEnabledStatus / BatchUpdateChunkTagID // --------------------------------------------------------------------------- // BatchUpdateChunkEnabledStatus 批量更新 chunk 的 is_enabled 字段。 // legacy 模式走 Stream Load partial update;inner_product_duplicate 模式改为读整行后 replaceRows 写回。 func (r *dorisRepository) BatchUpdateChunkEnabledStatus(ctx context.Context, chunkStatusMap map[string]bool, ) error { if len(chunkStatusMap) == 0 { return nil } compatMode, err := r.resolveCompatMode(ctx) if err != nil { return err } if !compatMode.usesRewriteChunkUpdates() { return r.batchUpdateChunkEnabledStatusLegacy(ctx, chunkStatusMap) } chunkIDs := make([]string, 0, len(chunkStatusMap)) for id := range chunkStatusMap { chunkIDs = append(chunkIDs, id) } return r.rewriteChunkRows(ctx, chunkIDs, func(row *DorisVectorEmbedding) bool { enabled, ok := chunkStatusMap[row.ChunkID] if !ok || row.IsEnabled == enabled { return false } row.IsEnabled = enabled return true }, "rewrite is_enabled") } // BatchUpdateChunkTagID 批量更新 chunk 的 tag_id 字段。逻辑与 EnabledStatus 一致。 func (r *dorisRepository) BatchUpdateChunkTagID(ctx context.Context, chunkTagMap map[string]string, ) error { if len(chunkTagMap) == 0 { return nil } compatMode, err := r.resolveCompatMode(ctx) if err != nil { return err } if !compatMode.usesRewriteChunkUpdates() { return r.batchUpdateChunkTagIDLegacy(ctx, chunkTagMap) } chunkIDs := make([]string, 0, len(chunkTagMap)) for id := range chunkTagMap { chunkIDs = append(chunkIDs, id) } return r.rewriteChunkRows(ctx, chunkIDs, func(row *DorisVectorEmbedding) bool { tagID, ok := chunkTagMap[row.ChunkID] if !ok || row.TagID == tagID { return false } row.TagID = tagID return true }, "rewrite tag_id") } func (r *dorisRepository) rewriteChunkRows(ctx context.Context, chunkIDs []string, mutate func(*DorisVectorEmbedding) bool, action string, ) error { if len(chunkIDs) == 0 { return nil } tables, err := r.listEmbeddingTables(ctx) if err != nil { return fmt.Errorf("list tables: %w", err) } for _, table := range tables { rows, err := r.loadRowsByChunkIDs(ctx, table, chunkIDs) if err != nil { return fmt.Errorf("load chunk rows from %s: %w", table, err) } updated := make([]*DorisVectorEmbedding, 0, len(rows)) for _, row := range rows { if !mutate(row) { continue } updated = append(updated, row) } if len(updated) == 0 { continue } if err := r.replaceRows(ctx, table, updated); err != nil { return fmt.Errorf("%s in %s: %w", action, table, err) } } return nil } func (r *dorisRepository) batchUpdateChunkEnabledStatusLegacy(ctx context.Context, chunkStatusMap map[string]bool, ) error { chunkIDs := make([]string, 0, len(chunkStatusMap)) for id := range chunkStatusMap { chunkIDs = append(chunkIDs, id) } mapping, err := r.lookupChunkRowKeys(ctx, chunkIDs) if err != nil { return err } byTable := make(map[string][]map[string]any) for chunkID, locations := range mapping { enabled, ok := chunkStatusMap[chunkID] if !ok { continue } for _, loc := range locations { byTable[loc.table] = append(byTable[loc.table], map[string]any{ fieldID: loc.id, fieldIsEnabled: enabled, }) } } for table, rows := range byTable { if err := r.partialUpdateRows(ctx, table, []string{fieldID, fieldIsEnabled}, rows); err != nil { return fmt.Errorf("partial update is_enabled in %s: %w", table, err) } } return nil } func (r *dorisRepository) batchUpdateChunkTagIDLegacy(ctx context.Context, chunkTagMap map[string]string, ) error { chunkIDs := make([]string, 0, len(chunkTagMap)) for id := range chunkTagMap { chunkIDs = append(chunkIDs, id) } mapping, err := r.lookupChunkRowKeys(ctx, chunkIDs) if err != nil { return err } byTable := make(map[string][]map[string]any) for chunkID, locations := range mapping { tagID, ok := chunkTagMap[chunkID] if !ok { continue } for _, loc := range locations { byTable[loc.table] = append(byTable[loc.table], map[string]any{ fieldID: loc.id, fieldTagID: tagID, }) } } for table, rows := range byTable { if err := r.partialUpdateRows(ctx, table, []string{fieldID, fieldTagID}, rows); err != nil { return fmt.Errorf("partial update tag_id in %s: %w", table, err) } } return nil } func (r *dorisRepository) loadRowsByChunkIDs(ctx context.Context, table string, chunkIDs []string, ) ([]*DorisVectorEmbedding, error) { if len(chunkIDs) == 0 { return nil, nil } placeholders := make([]string, len(chunkIDs)) args := make([]any, len(chunkIDs)) for i, v := range chunkIDs { placeholders[i] = "?" args[i] = v } stmt := fmt.Sprintf( "SELECT %s FROM `%s` WHERE %s IN (%s)", strings.Join(columnsForCopy, ", "), table, fieldChunkID, strings.Join(placeholders, ", "), ) rows, err := r.db.QueryContext(ctx, stmt, args...) if err != nil { return nil, err } batch, err := scanCopyRows(rows) _ = rows.Close() if err != nil { return nil, err } return batch, nil } // rowLocation 表示某行在哪个表里、主键 id 是什么。 type rowLocation struct { table string id string } // lookupChunkRowKeys 查询给定的 chunkIDs 在所有 _ 表中的物理位置: // - key:chunk_id // - value:[(table, id), ...],因为同一 chunk 可能在多个维度的表里都有副本。 // // 跨表查询使用 listEmbeddingTables 列出的所有匹配表;每张表执行一次 // SELECT id, chunk_id FROM WHERE chunk_id IN (?, ?, ...)。 func (r *dorisRepository) lookupChunkRowKeys(ctx context.Context, chunkIDs []string, ) (map[string][]rowLocation, error) { if len(chunkIDs) == 0 { return nil, nil } tables, err := r.listEmbeddingTables(ctx) if err != nil { return nil, fmt.Errorf("list tables: %w", err) } if len(tables) == 0 { return nil, nil } placeholders := make([]string, len(chunkIDs)) args := make([]any, len(chunkIDs)) for i, v := range chunkIDs { placeholders[i] = "?" args[i] = v } out := make(map[string][]rowLocation) for _, table := range tables { stmt := fmt.Sprintf( "SELECT %s, %s FROM `%s` WHERE %s IN (%s)", fieldID, fieldChunkID, table, fieldChunkID, strings.Join(placeholders, ", "), ) rows, err := r.db.QueryContext(ctx, stmt, args...) if err != nil { return nil, fmt.Errorf("lookup chunk row keys in %s: %w", table, err) } for rows.Next() { var id, chunkID string if err := rows.Scan(&id, &chunkID); err != nil { _ = rows.Close() return nil, fmt.Errorf("scan row keys: %w", err) } out[chunkID] = append(out[chunkID], rowLocation{table: table, id: id}) } if err := rows.Err(); err != nil { _ = rows.Close() return nil, err } _ = rows.Close() } return out, nil }