Files
xinmi/internal/application/repository/retriever/doris/streamload.go
T

509 lines
15 KiB
Go
Raw Normal View History

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 在所有 <base>_<dim> 表中的物理位置:
// - key:chunk_id
// - value:[(table, id), ...],因为同一 chunk 可能在多个维度的表里都有副本。
//
// 跨表查询使用 listEmbeddingTables 列出的所有匹配表;每张表执行一次
// SELECT id, chunk_id FROM <table> 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
}