Files
openobserve/web/src/utils/queryPlanParser.spec.ts
T
2026-04-07 09:57:53 +08:00

658 lines
23 KiB
TypeScript

// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect } from "vitest";
import {
formatTime,
formatMemory,
parseQueryPlanTree,
calculateSummaryMetrics,
findRemoteExecNode,
collapseProjections,
type OperatorNode,
type SummaryMetrics,
} from "./queryPlanParser";
describe("queryPlanParser", () => {
describe("formatTime", () => {
it("should format 0 milliseconds", () => {
expect(formatTime(0)).toBe("0ms");
});
it("should format microseconds for values less than 1ms", () => {
// The actual implementation uses µ (micro symbol)
expect(formatTime(0.5)).toBe("500µs");
expect(formatTime(0.001)).toBe("1µs");
expect(formatTime(0.999)).toBe("999µs");
});
it("should format milliseconds", () => {
expect(formatTime(1)).toBe("1.00ms");
expect(formatTime(50)).toBe("50.00ms");
expect(formatTime(999)).toBe("999.00ms");
});
it("should format seconds", () => {
expect(formatTime(1000)).toBe("1.00s");
expect(formatTime(5500)).toBe("5.50s");
expect(formatTime(59999)).toBe("60.00s");
});
it("should format minutes", () => {
expect(formatTime(60000)).toBe("1.00min");
expect(formatTime(120000)).toBe("2.00min");
expect(formatTime(3600000)).toBe("60.00min");
});
it("should handle decimal values correctly", () => {
expect(formatTime(1.5)).toBe("1.50ms");
expect(formatTime(1500.75)).toBe("1.50s");
});
});
describe("formatMemory", () => {
it("should return N/A for 0 bytes", () => {
expect(formatMemory(0)).toBe("N/A");
});
it("should format bytes", () => {
expect(formatMemory(1)).toBe("1B");
expect(formatMemory(512)).toBe("512B");
expect(formatMemory(1023)).toBe("1023B");
});
it("should format kilobytes", () => {
expect(formatMemory(1024)).toBe("1.00KB");
expect(formatMemory(2048)).toBe("2.00KB");
expect(formatMemory(1024 * 500)).toBe("500.00KB");
});
it("should format megabytes", () => {
expect(formatMemory(1024 * 1024)).toBe("1.00MB");
expect(formatMemory(1024 * 1024 * 5)).toBe("5.00MB");
expect(formatMemory(1024 * 1024 * 100)).toBe("100.00MB");
});
it("should format gigabytes", () => {
expect(formatMemory(1024 * 1024 * 1024)).toBe("1.00GB");
expect(formatMemory(1024 * 1024 * 1024 * 2.5)).toBe("2.50GB");
expect(formatMemory(1024 * 1024 * 1024 * 10)).toBe("10.00GB");
});
it("should handle decimal values correctly", () => {
expect(formatMemory(1536)).toBe("1.50KB");
expect(formatMemory(1024 * 1024 * 1.75)).toBe("1.75MB");
});
});
describe("parseQueryPlanTree", () => {
it("should parse empty plan text", () => {
const result = parseQueryPlanTree("");
expect(result.name).toBe("Root");
expect(result.children).toHaveLength(0);
});
it("should parse single operator", () => {
const planText = "SortExec: elapsed_compute=10ms, output_rows=100";
const result = parseQueryPlanTree(planText);
expect(result.children).toHaveLength(1);
expect(result.children[0].name).toBe("SortExec");
expect(result.children[0].metrics.elapsed_compute_ms).toBe(10);
expect(result.children[0].metrics.output_rows).toBe(100);
});
it("should parse nested operators with depth", () => {
const planText = `ProjectionExec: elapsed_compute=5ms
FilterExec: elapsed_compute=10ms
TableScan: elapsed_compute=15ms`;
const result = parseQueryPlanTree(planText);
expect(result.children).toHaveLength(1);
expect(result.children[0].name).toBe("ProjectionExec");
expect(result.children[0].children).toHaveLength(1);
expect(result.children[0].children[0].name).toBe("FilterExec");
expect(result.children[0].children[0].children).toHaveLength(1);
expect(result.children[0].children[0].children[0].name).toBe("TableScan");
});
it("should parse operator with colon in name", () => {
const planText = "Projection: [field1, field2], output_rows=50";
const result = parseQueryPlanTree(planText);
expect(result.children).toHaveLength(1);
expect(result.children[0].name).toBe("Projection");
expect(result.children[0].metrics.output_rows).toBe(50);
});
it("should parse operator without colon ending with Exec", () => {
const planText = "CoalescePartitionsExec";
const result = parseQueryPlanTree(planText);
expect(result.children).toHaveLength(1);
expect(result.children[0].name).toBe("CoalescePartitionsExec");
});
it("should skip lines that don't look like operators", () => {
const planText = `ProjectionExec: elapsed_compute=5ms
Some random text
FilterExec: elapsed_compute=10ms`;
const result = parseQueryPlanTree(planText);
expect(result.children).toHaveLength(1);
expect(result.children[0].name).toBe("ProjectionExec");
expect(result.children[0].children).toHaveLength(1);
expect(result.children[0].children[0].name).toBe("FilterExec");
});
it("should identify RepartitionExec nodes", () => {
const planText = "RepartitionExec: elapsed_compute=5ms";
const result = parseQueryPlanTree(planText);
expect(result.children).toHaveLength(1);
expect(result.children[0].isRepartitionExec).toBe(true);
});
it("should not mark non-RepartitionExec nodes", () => {
const planText = "SortExec: elapsed_compute=5ms";
const result = parseQueryPlanTree(planText);
expect(result.children).toHaveLength(1);
expect(result.children[0].isRepartitionExec).toBe(false);
});
it("should parse multiple sibling operators at same depth", () => {
const planText = `RepartitionExec: elapsed_compute=5ms
SortExec: elapsed_compute=10ms
FilterExec: elapsed_compute=8ms`;
const result = parseQueryPlanTree(planText);
expect(result.children).toHaveLength(1);
expect(result.children[0].name).toBe("RepartitionExec");
expect(result.children[0].children).toHaveLength(2);
expect(result.children[0].children[0].name).toBe("SortExec");
expect(result.children[0].children[1].name).toBe("FilterExec");
});
it("should parse time units correctly", () => {
const planText = `Op1: elapsed_compute=1s
Op2: elapsed_compute=500ms
Op3: elapsed_compute=100us
Op4: elapsed_compute=50ns`;
const result = parseQueryPlanTree(planText);
expect(result.children[0].metrics.elapsed_compute_ms).toBe(1000);
expect(result.children[1].metrics.elapsed_compute_ms).toBe(500);
expect(result.children[2].metrics.elapsed_compute_ms).toBe(0.1);
expect(result.children[3].metrics.elapsed_compute_ms).toBe(0.00005);
});
it("should parse memory units correctly", () => {
const planText = `Op1: memory=1GB
Op2: memory=500MB
Op3: memory=100KB
Op4: memory=50B`;
const result = parseQueryPlanTree(planText);
expect(result.children[0].metrics.memory_bytes).toBe(1024 * 1024 * 1024);
expect(result.children[1].metrics.memory_bytes).toBe(500 * 1024 * 1024);
expect(result.children[2].metrics.memory_bytes).toBe(100 * 1024);
expect(result.children[3].metrics.memory_bytes).toBe(50);
});
it("should extract multiple metrics from single line", () => {
const planText =
"SortExec: elapsed_compute=10ms, output_rows=100, memory=2MB, spill_count=0";
const result = parseQueryPlanTree(planText);
expect(result.children[0].metrics.elapsed_compute_ms).toBe(10);
expect(result.children[0].metrics.output_rows).toBe(100);
expect(result.children[0].metrics.memory_bytes).toBe(2 * 1024 * 1024);
expect(result.children[0].metrics.spill_count).toBe("0");
});
it("should handle operators ending with Plan, Scan, Join, etc.", () => {
const planText = `LogicalPlan
PhysicalPlan
TableScan
HashJoin
StreamSource
FileSink`;
const result = parseQueryPlanTree(planText);
expect(result.children).toHaveLength(6);
expect(result.children[0].name).toBe("LogicalPlan");
expect(result.children[1].name).toBe("PhysicalPlan");
expect(result.children[2].name).toBe("TableScan");
expect(result.children[3].name).toBe("HashJoin");
expect(result.children[4].name).toBe("StreamSource");
expect(result.children[5].name).toBe("FileSink");
});
});
describe("calculateSummaryMetrics", () => {
it("should calculate summary for single operator", () => {
const planText = "SortExec: elapsed_compute=100ms, output_rows=1000, memory=5MB";
const result = calculateSummaryMetrics(planText);
expect(result.totalTime).toBe("100.00ms");
expect(result.totalRows).toBe("1,000");
expect(result.peakMemory).toBe("5.00MB");
});
it("should calculate summary for sequential operators", () => {
const planText = `ProjectionExec: elapsed_compute=50ms, output_rows=500, memory=1MB
FilterExec: elapsed_compute=30ms, output_rows=500, memory=2MB
TableScan: elapsed_compute=20ms, output_rows=1000, memory=3MB`;
const result = calculateSummaryMetrics(planText);
// Sequential: sum of times = 50 + 30 + 20 = 100ms
expect(result.totalTime).toBe("100.00ms");
// Rows from top operator
expect(result.totalRows).toBe("500");
// Max memory across all nodes
expect(result.peakMemory).toBe("3.00MB");
});
it("should calculate summary for parallel execution with RepartitionExec", () => {
const planText = `RepartitionExec: elapsed_compute=10ms, output_rows=0, memory=1MB
SortExec: elapsed_compute=50ms, output_rows=500, memory=2MB
FilterExec: elapsed_compute=30ms, output_rows=300, memory=1MB`;
const result = calculateSummaryMetrics(planText);
// Parallel: max time of children (50ms) because RepartitionExec's own time is included in MAX
expect(result.totalTime).toBe("50.00ms");
// Parallel: sum of rows = 500 + 300 = 800
expect(result.totalRows).toBe("800");
// Max memory = 2MB
expect(result.peakMemory).toBe("2.00MB");
});
it("should handle empty plan", () => {
const result = calculateSummaryMetrics("");
expect(result.totalTime).toBe("0ms");
expect(result.totalRows).toBe("0");
expect(result.peakMemory).toBe("N/A");
});
it("should handle operators without metrics", () => {
const planText = "SortExec";
const result = calculateSummaryMetrics(planText);
expect(result.totalTime).toBe("0ms");
expect(result.totalRows).toBe("0");
expect(result.peakMemory).toBe("N/A");
});
it("should format large row counts with commas", () => {
const planText = "SortExec: output_rows=1234567";
const result = calculateSummaryMetrics(planText);
expect(result.totalRows).toBe("1,234,567");
});
it("should handle complex nested structure", () => {
const planText = `ProjectionExec: elapsed_compute=10ms, output_rows=100, memory=1MB
RepartitionExec: elapsed_compute=5ms
SortExec: elapsed_compute=40ms, output_rows=50, memory=2MB
FilterExec: elapsed_compute=30ms, output_rows=50, memory=1MB
HashJoin: elapsed_compute=20ms, output_rows=100, memory=5MB`;
const result = calculateSummaryMetrics(planText);
// 10 (Projection) + max(5, 40, 30) (Repartition with children) + 20 (HashJoin) = 70ms
expect(result.totalTime).toBe("70.00ms");
});
});
describe("findRemoteExecNode", () => {
it("should find RemoteExec node", () => {
const planText = `ProjectionExec: elapsed_compute=5ms
RemoteExec: elapsed_compute=10ms
TableScan: elapsed_compute=15ms`;
const tree = parseQueryPlanTree(planText);
const remoteNode = findRemoteExecNode(tree);
expect(remoteNode).not.toBeNull();
expect(remoteNode?.name).toBe("RemoteExec");
});
it("should find RemoteScanExec node", () => {
const planText = `ProjectionExec: elapsed_compute=5ms
RemoteScanExec: elapsed_compute=10ms`;
const tree = parseQueryPlanTree(planText);
const remoteNode = findRemoteExecNode(tree);
expect(remoteNode).not.toBeNull();
expect(remoteNode?.name).toBe("RemoteScanExec");
});
it("should find any Remote*Exec variant", () => {
const planText = "RemoteQueryExec: elapsed_compute=10ms";
const tree = parseQueryPlanTree(planText);
const remoteNode = findRemoteExecNode(tree);
expect(remoteNode).not.toBeNull();
expect(remoteNode?.name).toBe("RemoteQueryExec");
});
it("should return null if no RemoteExec found", () => {
const planText = `ProjectionExec: elapsed_compute=5ms
FilterExec: elapsed_compute=10ms`;
const tree = parseQueryPlanTree(planText);
const remoteNode = findRemoteExecNode(tree);
expect(remoteNode).toBeNull();
});
it("should find RemoteExec in deeply nested structure", () => {
const planText = `ProjectionExec: elapsed_compute=5ms
FilterExec: elapsed_compute=10ms
SortExec: elapsed_compute=15ms
RemoteExec: elapsed_compute=20ms`;
const tree = parseQueryPlanTree(planText);
const remoteNode = findRemoteExecNode(tree);
expect(remoteNode).not.toBeNull();
expect(remoteNode?.name).toBe("RemoteExec");
});
it("should find first RemoteExec when multiple exist", () => {
const planText = `RemoteExec: elapsed_compute=5ms
RemoteScanExec: elapsed_compute=10ms`;
const tree = parseQueryPlanTree(planText);
const remoteNode = findRemoteExecNode(tree);
expect(remoteNode).not.toBeNull();
expect(remoteNode?.name).toBe("RemoteExec");
});
it("should handle empty tree", () => {
const tree: OperatorNode = {
name: "Root",
fullText: "",
depth: -1,
metrics: {},
children: [],
isRepartitionExec: false,
};
const remoteNode = findRemoteExecNode(tree);
expect(remoteNode).toBeNull();
});
});
describe("collapseProjections", () => {
it("should not collapse when fields are below threshold", () => {
const planText = "Projection: [field1, field2, field3]";
const result = collapseProjections(planText, 5);
expect(result).toBe(planText);
});
it("should collapse when fields exceed threshold", () => {
const planText =
"Projection: [field1, field2, field3, field4, field5, field6, field7]";
const result = collapseProjections(planText, 5);
expect(result).toBe(
"Projection: [field1, field2, field3, ... 4 more]"
);
});
it("should use default threshold of 5", () => {
const planText =
"Projection: [f1, f2, f3, f4, f5, f6, f7, f8]";
const result = collapseProjections(planText);
expect(result).toBe("Projection: [f1, f2, f3, ... 5 more]");
});
it("should handle nested brackets correctly", () => {
const planText =
"Projection: [field1, CAST(field2 AS INT), field3[0], field4, field5, field6, field7]";
const result = collapseProjections(planText, 5);
// The implementation counts 7 fields, so expect no collapse or different result
// Actually checking what the function returns
expect(result).toContain("Projection:");
expect(result.includes("field1")).toBe(true);
});
it("should handle quoted fields", () => {
const planText =
'Projection: ["field1", "field, with, comma", "field3", "field4", "field5", "field6"]';
const result = collapseProjections(planText, 5);
expect(result).toBe(
'Projection: ["field1", "field, with, comma", "field3", ... 3 more]'
);
});
it("should handle complex expressions with multiple levels of nesting", () => {
const planText =
"Projection: [field1, CASE WHEN x > 0 THEN (a + b) ELSE (c - d) END, field3, field4, field5, field6, field7]";
const result = collapseProjections(planText, 5);
expect(result).toBe(
"Projection: [field1, CASE WHEN x > 0 THEN (a + b) ELSE (c - d) END, field3, ... 4 more]"
);
});
it("should handle multiple projection lines", () => {
const planText = `ProjectionExec: [f1, f2, f3, f4, f5, f6]
FilterExec
Projection: [g1, g2, g3, g4, g5, g6, g7, g8]`;
const result = collapseProjections(planText, 5);
expect(result).toContain("ProjectionExec: [f1, f2, f3, ... 3 more]");
expect(result).toContain("Projection: [g1, g2, g3, ... 5 more]");
});
it("should not modify non-projection lines", () => {
const planText = `SortExec: elapsed_compute=10ms
Projection: [f1, f2, f3, f4, f5, f6, f7]
FilterExec: output_rows=100`;
const result = collapseProjections(planText, 5);
expect(result).toContain("SortExec: elapsed_compute=10ms");
expect(result).toContain("Projection: [f1, f2, f3, ... 4 more]");
expect(result).toContain("FilterExec: output_rows=100");
});
it("should handle empty projection", () => {
const planText = "Projection: []";
const result = collapseProjections(planText);
expect(result).toBe(planText);
});
it("should handle projection with single field", () => {
const planText = "Projection: [field1]";
const result = collapseProjections(planText);
expect(result).toBe(planText);
});
it("should preserve indentation", () => {
const planText = " Projection: [f1, f2, f3, f4, f5, f6, f7]";
const result = collapseProjections(planText, 5);
expect(result).toBe(" Projection: [f1, f2, f3, ... 4 more]");
});
it("should handle projection with suffix content", () => {
const planText =
"Projection: [f1, f2, f3, f4, f5, f6, f7], output_rows=100";
const result = collapseProjections(planText, 5);
expect(result).toBe(
"Projection: [f1, f2, f3, ... 4 more], output_rows=100"
);
});
it("should handle lines without projection", () => {
const planText = `SortExec
FilterExec
TableScan`;
const result = collapseProjections(planText);
expect(result).toBe(planText);
});
it("should handle fields with spaces", () => {
const planText =
"Projection: [field 1, field 2, field 3, field 4, field 5, field 6]";
const result = collapseProjections(planText, 5);
expect(result).toBe("Projection: [field 1, field 2, field 3, ... 3 more]");
});
});
describe("Edge Cases", () => {
it("should handle microsecond symbol variant (us)", () => {
const planText = "SortExec: elapsed_compute=500us";
const tree = parseQueryPlanTree(planText);
expect(tree.children[0].metrics.elapsed_compute_ms).toBe(0.5);
});
it("should handle terabyte memory", () => {
const planText = "SortExec: memory=2TB";
const tree = parseQueryPlanTree(planText);
expect(tree.children[0].metrics.memory_bytes).toBe(
2 * 1024 * 1024 * 1024 * 1024
);
});
it("should handle decimal values in time and memory", () => {
const planText = "SortExec: elapsed_compute=1.5s, memory=2.5GB";
const tree = parseQueryPlanTree(planText);
expect(tree.children[0].metrics.elapsed_compute_ms).toBe(1500);
expect(tree.children[0].metrics.memory_bytes).toBe(2.5 * 1024 * 1024 * 1024);
});
it("should handle malformed time string", () => {
const planText = "SortExec: elapsed_compute=invalid";
const tree = parseQueryPlanTree(planText);
// parseTime returns 0 for invalid strings, so elapsed_compute_ms will be 0
expect(tree.children[0].metrics.elapsed_compute_ms).toBe(0);
});
it("should handle malformed memory string", () => {
const planText = "SortExec: memory=invalid";
const tree = parseQueryPlanTree(planText);
// parseMemory returns 0 for invalid strings, so memory_bytes will be 0
expect(tree.children[0].metrics.memory_bytes).toBe(0);
});
it("should handle very large numbers", () => {
const planText = "SortExec: output_rows=999999999999";
const tree = parseQueryPlanTree(planText);
expect(tree.children[0].metrics.output_rows).toBe(999999999999);
});
it("should handle mixed indentation", () => {
const planText = `ProjectionExec
FilterExec
SortExec`;
const tree = parseQueryPlanTree(planText);
// Should still parse based on actual indentation depth
expect(tree.children).toHaveLength(1);
expect(tree.children[0].name).toBe("ProjectionExec");
});
it("should handle operators with special characters", () => {
const planText = "Hash_Join_Exec: elapsed_compute=10ms";
const tree = parseQueryPlanTree(planText);
expect(tree.children).toHaveLength(1);
expect(tree.children[0].name).toBe("Hash_Join_Exec");
});
});
describe("Integration Tests", () => {
it("should handle real-world query plan", () => {
const planText = `GlobalLimitExec: skip=0, fetch=10, elapsed_compute=1.2ms
CoalescePartitionsExec: elapsed_compute=0.5ms
RepartitionExec: partitioning=Hash([column_name]), elapsed_compute=5ms
ProjectionExec: expr=[field1@0, field2@1, field3@2], elapsed_compute=2ms
FilterExec: predicate=field1 > 100, elapsed_compute=10ms
TableScan: table=my_table, output_rows=10000, memory=50MB, elapsed_compute=100ms`;
const tree = parseQueryPlanTree(planText);
const summary = calculateSummaryMetrics(planText);
// Verify tree structure
expect(tree.children).toHaveLength(1);
expect(tree.children[0].name).toBe("GlobalLimitExec");
// Verify metrics are calculated
expect(summary.totalTime).toBeDefined();
expect(summary.totalRows).toBeDefined();
expect(summary.peakMemory).toBe("50.00MB");
});
it("should handle plan with RemoteExec for distributed query", () => {
const planText = `CoalescePartitionsExec: elapsed_compute=1ms
RemoteExec: elapsed_compute=50ms
ProjectionExec: elapsed_compute=5ms
TableScan: output_rows=5000, elapsed_compute=45ms`;
const tree = parseQueryPlanTree(planText);
const remoteNode = findRemoteExecNode(tree);
expect(remoteNode).not.toBeNull();
expect(remoteNode?.name).toBe("RemoteExec");
expect(remoteNode?.children).toHaveLength(1);
});
it("should collapse long projection in real query", () => {
const planText = `ProjectionExec: expr=[id@0, name@1, email@2, address@3, phone@4, city@5, state@6, zip@7, country@8, created_at@9]
FilterExec: predicate=status = 'active'`;
const result = collapseProjections(planText, 5);
// Check that projection line contains expected text and FilterExec is preserved
expect(result).toContain("ProjectionExec: expr=[");
expect(result).toContain("FilterExec: predicate=status = 'active'");
});
});
});