Skip to content

Commit 4913479

Browse files
authored
Merge pull request #871 from sipcapture/fix/870-form-search-all-event-types
fix(search): merge call/registration/default tables for Form search event_type=all
2 parents ab94505 + 3d2371b commit 4913479

6 files changed

Lines changed: 311 additions & 20 deletions

File tree

src/coordinator/handlers/search.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,15 @@ func (h *SearchHandler) buildSimpleSearchSQL(req *SimpleSearchRequest) (string,
257257

258258
// normalizeSIPTransactionType maps UI / filter event_type to DuckLake SIP sub-type
259259
// (must stay in sync with getTableName).
260+
//
261+
// "all" (see isAllEventType) is NOT resolved here: it addresses every
262+
// physical event-type table at once and callers that support merging
263+
// (currently V4TransactionsSearch, see transactionSearchWantsAllEventTypes)
264+
// must intercept it before reaching this function / getTableName. Any "all"
265+
// value that does reach here (e.g. aggregation searches, which cannot be
266+
// merged in Go) falls into the default branch below and resolves to the
267+
// single "default" table, matching the historical behavior for unknown
268+
// event_type values.
260269
func normalizeSIPTransactionType(protoType int, transactionType string) string {
261270
if protoType == 0 {
262271
protoType = 1
@@ -280,6 +289,19 @@ func normalizeSIPTransactionType(protoType int, transactionType string) string {
280289
return transactionType
281290
}
282291

292+
// isAllEventType reports whether a UI/API event_type filter requests a
293+
// merged view across every physical SIP event-type table (call/registration/
294+
// default) instead of addressing a single one. See
295+
// transactionSearchWantsAllEventTypes / queryTransactionSearchAllEventTypes
296+
// for where this merge is actually performed.
297+
func isAllEventType(eventType string) bool {
298+
switch strings.ToLower(strings.TrimSpace(eventType)) {
299+
case "all", "*":
300+
return true
301+
}
302+
return false
303+
}
304+
283305
// OTLP "virtual" hepid range, mirrored in the seeded mapping_schema rows
284306
// (see services/mapping_seed.go). These do not collide with real HEP types
285307
// — they let the Proto Search widget address the dedicated otlp_* DuckLake

src/coordinator/handlers/transactions_v4.go

Lines changed: 177 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,84 @@ func transactionSearchTopNShape(req *SearchObjectV4) bool {
698698
strings.EqualFold(orderBy, "time desc")
699699
}
700700

701+
// sipEventSubTables are the physical hep_proto_1_<x> tables merged by
702+
// queryTransactionSearchAllEventTypes for a SIP (proto_type 1) search whose
703+
// event_type filter is "all" (see isAllEventType). siprec is intentionally
704+
// excluded: it is not exposed as a Form-search event_type option today.
705+
var sipEventSubTables = []string{"call", "registration", "default"}
706+
707+
// transactionSearchWantsAllEventTypes reports whether a v4 transaction search
708+
// should merge results across every physical SIP event-type table
709+
// (hep_proto_1_call / _registration / _default) instead of a single one.
710+
//
711+
// Only proto_type 1 (SIP) has this call/registration/default split — OTLP
712+
// and Line Protocol tables do not. Aggregations (custom select/group_by) and
713+
// non-default ordering are excluded too: their rows cannot be correctly
714+
// merged across tables at the Go layer (see
715+
// queryTransactionSearchAllEventTypes), so those fall back to the single
716+
// "default" table via normalizeSIPTransactionType's default branch.
717+
func transactionSearchWantsAllEventTypes(req *SearchObjectV4) bool {
718+
protoType := req.Filter.ProtoType
719+
if protoType == 0 {
720+
protoType = 1
721+
}
722+
if protoType != 1 {
723+
return false
724+
}
725+
if !isAllEventType(req.Filter.EventType) {
726+
return false
727+
}
728+
return transactionSearchTopNShape(req)
729+
}
730+
731+
// queryTransactionSearchAllEventTypes runs the normal single-table v4 search
732+
// pipeline (SQL build + queryTransactionSearch, including lazy payload
733+
// hydration and the configured lake_topn_strategy) once per physical
734+
// event-type table, then merges the rows newest-first and re-applies the
735+
// requested LIMIT.
736+
//
737+
// The merge happens in Go, not via a SQL UNION, so each sub-query keeps
738+
// going through the existing, well-tested single-table hot+cold/tiered-volume
739+
// query path unchanged (see node.rewriteQueryForVolumes / node.
740+
// buildMemoryUnionQueries) — those rewrites key off a single table reference
741+
// per query today, so folding three different tables into one UNION-based
742+
// query text would silently drop cold/memory-buffer rows for two of the
743+
// three tables instead of fixing the "Form search misses cold data" class of
744+
// bug this is meant to address.
745+
func (h *SearchHandler) queryTransactionSearchAllEventTypes(ctx context.Context, req *SearchObjectV4, virtualRules map[string]services.VirtualFieldRule) ([]map[string]interface{}, error) {
746+
limit := effectiveSearchLimit(req.Param.Limit)
747+
748+
var (
749+
all []map[string]interface{}
750+
firstErr error
751+
okCount int
752+
)
753+
for _, evt := range sipEventSubTables {
754+
subReq := *req
755+
subReq.Filter.EventType = evt
756+
757+
sql, err := buildSearchSQLV4(h.flightService.LakeName(), &subReq, virtualRules)
758+
if err != nil {
759+
return nil, fmt.Errorf("event_type=%s: %w", evt, err)
760+
}
761+
rows, err := h.queryTransactionSearch(ctx, sql, &subReq, virtualRules)
762+
if err != nil {
763+
logger.Warn("V4TransactionsSearch: event_type=all sub-query failed",
764+
"event_type", evt, "error", err)
765+
if firstErr == nil {
766+
firstErr = err
767+
}
768+
continue
769+
}
770+
okCount++
771+
all = append(all, rows...)
772+
}
773+
if okCount == 0 && firstErr != nil {
774+
return nil, firstErr
775+
}
776+
return sortRowsByTimestampDescLimit(all, limit), nil
777+
}
778+
701779
// queryTransactionSearch executes a v4 transaction search. fullSQL is the
702780
// already-validated query for the whole time range. The lake_topn_strategy
703781
// config selects how a long-range timestamp-DESC top-N is run:
@@ -932,16 +1010,28 @@ func (h *SearchHandler) V4TransactionsSearch(c echo.Context) error {
9321010
}
9331011

9341012
virtualRules := h.loadVirtualRulesForReq(c.Request().Context(), &req)
935-
sql, err := buildSearchSQLV4(h.flightService.LakeName(), &req, virtualRules)
936-
if err != nil {
937-
logger.Error(fmt.Sprintf("V4TransactionsSearch: SQL validation failed: %v", err))
938-
return writeError(c, http.StatusBadRequest, "Bad Request", fmt.Sprintf("SQL validation failed: %v", err))
939-
}
940-
logger.Info("V4TransactionsSearch", "proto", req.Filter.ProtoType, "event", req.Filter.EventType, "sql", sql)
941-
results, err := h.queryTransactionSearch(c.Request().Context(), sql, &req, virtualRules)
942-
if err != nil {
943-
logger.Error(fmt.Sprintf("V4TransactionsSearch: query error: %v", err))
944-
return writeError(c, http.StatusInternalServerError, "Server Error", "Query failed")
1013+
1014+
var results []map[string]interface{}
1015+
if transactionSearchWantsAllEventTypes(&req) {
1016+
rows, err := h.queryTransactionSearchAllEventTypes(c.Request().Context(), &req, virtualRules)
1017+
if err != nil {
1018+
logger.Error(fmt.Sprintf("V4TransactionsSearch: query error: %v", err))
1019+
return writeError(c, http.StatusInternalServerError, "Server Error", "Query failed")
1020+
}
1021+
results = rows
1022+
} else {
1023+
sql, err := buildSearchSQLV4(h.flightService.LakeName(), &req, virtualRules)
1024+
if err != nil {
1025+
logger.Error(fmt.Sprintf("V4TransactionsSearch: SQL validation failed: %v", err))
1026+
return writeError(c, http.StatusBadRequest, "Bad Request", fmt.Sprintf("SQL validation failed: %v", err))
1027+
}
1028+
logger.Info("V4TransactionsSearch", "proto", req.Filter.ProtoType, "event", req.Filter.EventType, "sql", sql)
1029+
rows, err := h.queryTransactionSearch(c.Request().Context(), sql, &req, virtualRules)
1030+
if err != nil {
1031+
logger.Error(fmt.Sprintf("V4TransactionsSearch: query error: %v", err))
1032+
return writeError(c, http.StatusInternalServerError, "Server Error", "Query failed")
1033+
}
1034+
results = rows
9451035
}
9461036
logger.Info("V4TransactionsSearch: got results", "count", len(results))
9471037

@@ -958,6 +1048,26 @@ func (h *SearchHandler) V4TransactionsSearch(c echo.Context) error {
9581048
return c.JSON(http.StatusOK, resp)
9591049
}
9601050

1051+
// resolveSearchTables returns the physical DuckLake tables a v4 lookup
1052+
// (message/transaction detail endpoints) should query for a given
1053+
// proto_type/event_type. For SIP (proto_type 1) with event_type "all" (see
1054+
// isAllEventType) this is every hep_proto_1_<x> table the Form-search merge
1055+
// can return rows from (sipEventSubTables); otherwise it is the single table
1056+
// getTableName would have resolved on its own.
1057+
func resolveSearchTables(lakeName string, protoType int, eventType string) []string {
1058+
if protoType == 0 {
1059+
protoType = 1
1060+
}
1061+
if protoType == 1 && isAllEventType(eventType) {
1062+
tables := make([]string, len(sipEventSubTables))
1063+
for i, evt := range sipEventSubTables {
1064+
tables[i] = getTableName(lakeName, protoType, evt)
1065+
}
1066+
return tables
1067+
}
1068+
return []string{getTableName(lakeName, protoType, eventType)}
1069+
}
1070+
9611071
// queryTransactionMessages loads SIP (or other proto) rows for a transaction session request (same rules as POST /transactions/messages).
9621072
//
9631073
// When a Lua correlation script is registered for (proto_type, event_type) the
@@ -967,6 +1077,11 @@ func (h *SearchHandler) V4TransactionsSearch(c echo.Context) error {
9671077
// session_ids, reissue the query on the expanded set and return the
9681078
// merged result. Any script/SQL failure is non-fatal — the handler falls
9691079
// back to the base rows and logs a warning.
1080+
//
1081+
// event_type "all" (see isAllEventType) queries every physical SIP
1082+
// event-type table and merges the rows; correlation is skipped in that case
1083+
// since a Lua script is registered against a single (proto_type, event_type)
1084+
// pair and the merged view has no single one.
9701085
func (h *SearchHandler) queryTransactionMessages(ctx context.Context, req *TransactionSessionRequestV4) ([]map[string]interface{}, error) {
9711086
multi := normalizeTransactionSessionIDs(req.SessionIDs)
9721087
if len(multi) == 0 {
@@ -984,7 +1099,29 @@ func (h *SearchHandler) queryTransactionMessages(ctx context.Context, req *Trans
9841099
if eventType == "" {
9851100
eventType = "call"
9861101
}
987-
table := getTableName(h.flightService.LakeName(), protoType, eventType)
1102+
1103+
tables := resolveSearchTables(h.flightService.LakeName(), protoType, eventType)
1104+
if len(tables) > 1 {
1105+
var merged []map[string]interface{}
1106+
var firstErr error
1107+
for _, table := range tables {
1108+
rows, err := h.executeTransactionMessagesSQL(ctx, table, multi, req.Timestamp.From, req.Timestamp.To)
1109+
if err != nil {
1110+
logger.Warn("V4TransactionMessages: event_type=all sub-query failed", "table", table, "error", err)
1111+
if firstErr == nil {
1112+
firstErr = err
1113+
}
1114+
continue
1115+
}
1116+
merged = append(merged, rows...)
1117+
}
1118+
if merged == nil && firstErr != nil {
1119+
return nil, firstErr
1120+
}
1121+
sortTransactionMessageRowsByTimestampAsc(merged)
1122+
return merged, nil
1123+
}
1124+
table := tables[0]
9881125

9891126
baseRows, err := h.executeTransactionMessagesSQL(ctx, table, multi, req.Timestamp.From, req.Timestamp.To)
9901127
if err != nil {
@@ -1175,14 +1312,12 @@ func (h *SearchHandler) V4MessageGet(c echo.Context) error {
11751312
if eventType == "" {
11761313
eventType = "call"
11771314
}
1178-
table := getTableName(h.flightService.LakeName(), protoType, eventType)
11791315
where := fmt.Sprintf("uuid = '%s'", sqlvalidator.SafeString(req.UUID))
11801316
if req.Timestamp.From > 0 && req.Timestamp.To > 0 {
11811317
where += fmt.Sprintf(" AND timestamp >= (to_timestamp(%d / 1000.0) AT TIME ZONE 'UTC') AND timestamp <= (to_timestamp(%d / 1000.0) AT TIME ZONE 'UTC')",
11821318
req.Timestamp.From, req.Timestamp.To)
11831319
}
1184-
sql := fmt.Sprintf("SELECT * FROM %s WHERE %s LIMIT 1", table, where)
1185-
results, err := h.flightService.Query(c.Request().Context(), sql)
1320+
results, err := h.queryMessageByUUIDAcrossTables(c.Request().Context(), protoType, eventType, where)
11861321
if err != nil {
11871322
return writeError(c, http.StatusInternalServerError, "Server Error", "Query failed")
11881323
}
@@ -1197,6 +1332,33 @@ func (h *SearchHandler) V4MessageGet(c echo.Context) error {
11971332
return c.JSON(http.StatusOK, resp)
11981333
}
11991334

1335+
// queryMessageByUUIDAcrossTables runs a "SELECT * FROM <table> WHERE <where> LIMIT 1"
1336+
// lookup, trying every table resolveSearchTables returns (more than one only
1337+
// when event_type is "all") until one produces a row. uuid is assigned once
1338+
// per message at ingest, so at most one table is expected to match; trying
1339+
// the rest costs one cheap indexed-column lookup each.
1340+
func (h *SearchHandler) queryMessageByUUIDAcrossTables(ctx context.Context, protoType int, eventType, where string) ([]map[string]interface{}, error) {
1341+
tables := resolveSearchTables(h.flightService.LakeName(), protoType, eventType)
1342+
var firstErr error
1343+
for _, table := range tables {
1344+
sql := fmt.Sprintf("SELECT * FROM %s WHERE %s LIMIT 1", table, where)
1345+
rows, err := h.flightService.Query(ctx, sql)
1346+
if err != nil {
1347+
if firstErr == nil {
1348+
firstErr = err
1349+
}
1350+
continue
1351+
}
1352+
if len(rows) > 0 {
1353+
return rows, nil
1354+
}
1355+
}
1356+
if firstErr != nil {
1357+
return nil, firstErr
1358+
}
1359+
return nil, nil
1360+
}
1361+
12001362
func (h *SearchHandler) V4MessageDecoded(c echo.Context) error {
12011363
var req MessageGetRequestV4
12021364
if err := c.Bind(&req); err != nil {
@@ -1214,14 +1376,12 @@ func (h *SearchHandler) V4MessageDecoded(c echo.Context) error {
12141376
if eventType == "" {
12151377
eventType = "call"
12161378
}
1217-
table := getTableName(h.flightService.LakeName(), protoType, eventType)
12181379
where := fmt.Sprintf("uuid = '%s'", sqlvalidator.SafeString(req.UUID))
12191380
if req.Timestamp.From > 0 && req.Timestamp.To > 0 {
12201381
where += fmt.Sprintf(" AND timestamp >= (to_timestamp(%d / 1000.0) AT TIME ZONE 'UTC') AND timestamp <= (to_timestamp(%d / 1000.0) AT TIME ZONE 'UTC')",
12211382
req.Timestamp.From, req.Timestamp.To)
12221383
}
1223-
sql := fmt.Sprintf("SELECT * FROM %s WHERE %s LIMIT 1", table, where)
1224-
results, err := h.flightService.Query(c.Request().Context(), sql)
1384+
results, err := h.queryMessageByUUIDAcrossTables(c.Request().Context(), protoType, eventType, where)
12251385
if err != nil {
12261386
return writeError(c, http.StatusInternalServerError, "Server Error", "Query failed")
12271387
}

0 commit comments

Comments
 (0)