Files
openobserve/tests/test-data/query-agent/compute_counts.py
T
Neha PandClaude Opus 4.7 9af9a47fea test: add error path, explain analyze, and concurrency stress tests for query agent (#12809)
## Summary
- **Phase 4 — Error Paths** (`test_4_error_paths.py`): 28 cases across 8
categories that send intentionally invalid SQL and assert 4xx not 5xx.
Self-contained, runs in ~24s without data dependency.
- **Phase 5 — Concurrency Stress** (`test_5_concurrency.py`): 26 queries
(2 per category) run simultaneously at 5/10/15 worker levels via
ThreadPoolExecutor. Each thread gets its own client. Catches deadlocks
and thread-safety bugs.
- **EXPLAIN ANALYZE** (`test_explain_analyze.py`): Verifies
EXPLAIN/EXPLAIN ANALYZE return 200 with plan output and no
`partial_error`. Also validates EXPLAIN ANALYZE on cross-schema
dot-notation queries returns 4xx (not 500 crash).
- **Fix**: `load_all_queries()` now skips JSON files without a
`"queries"` key.

## Test plan
- [x] Error path tests pass on pentest (28/28, all return 400)
- [ ] CI run: `rye run pytest tests/query_agent/test_4_error_paths.py
-v`
- [ ] CI run: `rye run pytest tests/query_agent/test_5_concurrency.py
-v`
- [ ] CI run: `rye run pytest tests/query_agent/test_explain_analyze.py
-v`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-14 05:08:33 +00:00

555 lines
21 KiB
Python

"""Compute expected result sets via DuckDB for all replicable queries.
Uses a pre-filtered DuckDB VIEW so the time-filter works for ALL queries
regardless of complexity (CTEs, subqueries, UNIONs, etc.) — no fragile
SQL WHERE injection needed.
sqllogictest-style: generates the exact expected output for each query so
the test runner can compare OO results cell-by-cell.
One-time tool — not a runtime test dependency.
Usage:
cd tests/test-data/query-agent && python3 compute_counts.py
"""
import json
import re
import tempfile
from pathlib import Path
import duckdb
from data_gen import BASE_TS, build_dataset
QUERIES_DIR = Path(__file__).parent / "queries"
STREAM = "logs"
# ── OO → DuckDB SQL translation ─────────────────────────────────────────────
def _replace_re_match(sql: str) -> str:
"""Replace re_match(..., 'pat') with regexp_matches(..., 'pat')."""
result = []
i = 0
while i < len(sql):
m = re.match(r're_match\s*\(', sql[i:], re.IGNORECASE)
if not m:
result.append(sql[i])
i += 1
continue
i += m.end()
depth = 1
j = i
while j < len(sql) and depth > 0:
if sql[j] == '(':
depth += 1
elif sql[j] == ')':
depth -= 1
j += 1
paren_close_at = j - 1
content = sql[i:paren_close_at]
comma_pos = -1
cdepth = 0
for k, ch in enumerate(content):
if ch == '(':
cdepth += 1
elif ch == ')':
cdepth -= 1
elif ch == ',' and cdepth == 0:
comma_pos = k
break
if comma_pos < 0:
result.append(sql[i - len(m.group()):j])
i = j
continue
arg1 = content[:comma_pos].strip()
arg2 = content[comma_pos + 1:].strip()
result.append(f"regexp_matches({arg1}, {arg2})")
i = j
if sql[i:i + 7].upper() == ' = TRUE':
i += 7
return ''.join(result)
def _replace_match_all(sql: str) -> str:
"""Replace match_all('term') with DuckDB LIKE conditions.
Tantivy tokenizes the term and matches each token independently
across all indexed text fields. We emulate this with per-word LIKE
conditions on the log field (the primary FTS field in test data).
Single word: match_all('warehouse') → log LIKE '%warehouse%'
Multi-word: match_all('ACK batch') → (log LIKE '%ACK%' AND log LIKE '%batch%')
Note: This is an approximation — Tantivy's actual tokenization may
differ from whitespace splitting (stemming, stop words, case folding).
The per-token content assertion in the test runner provides a
correctness backstop that is independent of this oracle.
"""
def _escape_like(s: str) -> str:
# Escape LIKE wildcards and single quotes to prevent unintended
# matches and SQL syntax breaks. Not a parameterized query, but
# this is test infrastructure against a local in-memory DuckDB.
return s.replace("'", "''").replace("%", "\\%").replace("_", "\\_")
def _build_like(m: re.Match) -> str:
term = m.group(1)
words = [_escape_like(w) for w in term.split()]
if len(words) == 1:
return f"log LIKE '%{words[0]}%'"
clauses = " AND ".join(f"log LIKE '%{w}%'" for w in words)
return f"({clauses})"
return re.sub(
r"match_all\('([^']*)'\)",
_build_like,
sql,
flags=re.IGNORECASE,
)
def _replace_str_match_ignore_case(sql: str) -> str:
"""Replace str_match_ignore_case(field, 'pat') with lower(field) LIKE '%pat%'."""
return re.sub(
r"str_match_ignore_case\((\w+),\s*'([^']*)'\)",
lambda m: f"lower({m.group(1)}) LIKE '%{m.group(2).lower()}%'",
sql,
flags=re.IGNORECASE,
)
def _replace_regexp_like(sql: str) -> str:
"""Replace regexp_like(field, 'pat'[, 'flags']) with regexp_matches(field, 'pat'[, 'flags'])."""
# Handle optional third argument (flags)
return re.sub(
r"regexp_like\s*\(\s*(\w+)\s*,\s*('(?:[^'\\]|\\.)*')\s*(?:,\s*('[^']*')\s*)?\)",
lambda m: f"regexp_matches({m.group(1)}, {m.group(2)}"
+ (f", {m.group(3)}" if m.group(3) else "") + ")",
sql,
flags=re.IGNORECASE,
)
def _replace_approx_distinct(sql: str) -> str:
"""Replace approx_distinct(field) with approx_count_distinct(field)."""
return re.sub(
r"approx_distinct\s*\(\s*([^)]+)\s*\)",
r"approx_count_distinct(\1)",
sql,
flags=re.IGNORECASE,
)
def _replace_json_get_str_chained(sql: str) -> str:
"""Replace OO json_get_str/json_get_json with DuckDB json_extract_string.
json_get_str(json_get_json(field, 'k1'), 'k2') → json_extract_string(field, '$.k1.k2')
json_get_str(field, 'key') → json_extract_string(field, '$.key')
json_get_json(field, 'key') → json_extract(field, '$.key')
"""
# Chain: json_get_str(json_get_json(field, 'k1'), 'k2') -> json_extract_string(field, '$.k1.k2')
pattern = r"json_get_str\(json_get_json\((\w+),\s*'([^']+)'\),\s*'([^']+)'\)"
while re.search(pattern, sql, re.IGNORECASE):
sql = re.sub(pattern, r"json_extract_string(\1, '$.\2.\3')", sql, flags=re.IGNORECASE)
# Single json_get_str(field, 'key') -> json_extract_string(field, '$.key')
sql = re.sub(
r"json_get_str\((\w+),\s*'([^']+)'\)",
r"json_extract_string(\1, '$.\2')",
sql,
flags=re.IGNORECASE,
)
# Single json_get_json(field, 'key') -> json_extract(field, '$.key')
sql = re.sub(
r"json_get_json\((\w+),\s*'([^']+)'\)",
r"json_extract(\1, '$.\2')",
sql,
flags=re.IGNORECASE,
)
return sql
def _has_oo_specific_functions(sql: str) -> bool:
"""Check if SQL uses OO-specific array functions that DuckDB cannot replicate."""
oo_patterns = [
r'\bcast_to_arr\s*\(', r'\barray_has\s*\(',
r'\bspath\s*\(', r'\bunnest\s*\(',
r'\bflatten\s*\(', r'\barray_element\s*\(',
]
return any(re.search(p, sql, re.IGNORECASE) for p in oo_patterns)
def _replace_histogram(sql: str) -> tuple[str, bool]:
"""Translate histogram(_timestamp, 'N unit') → DuckDB integer arithmetic.
OO's histogram is internally rewritten to DataFusion's:
date_bin(interval, to_timestamp_micros(_timestamp),
to_timestamp('2001-01-01T00:00:00'))
Pure integer arithmetic (works in ALL DuckDB versions):
origin + FLOOR((_timestamp - origin) / bucket_us) * bucket_us
where origin = 978307200000000 (2001-01-01 UTC in microseconds).
Returns (modified_sql, has_auto_interval). Auto-interval queries
(no second argument) cannot be replicated and must stay legacy.
"""
if re.search(r'histogram\(_timestamp\)', sql, re.IGNORECASE):
return sql, True
_ORIGIN_US = 978307200000000 # 2001-01-01T00:00:00 UTC in micros
# Map unit strings to microseconds
def _bucket_us(m: re.Match) -> str:
n = int(m.group(1))
unit = m.group(2)
if unit.startswith("second"):
width = n * 1_000_000
elif unit.startswith("minute"):
width = n * 60_000_000
elif unit.startswith("hour"):
width = n * 3_600_000_000
elif unit.startswith("day"):
width = n * 86_400_000_000
else:
width = n * 1_000_000
return f"CAST({_ORIGIN_US} + FLOOR((_timestamp - {_ORIGIN_US}) / {width}) * {width} AS BIGINT)"
pattern = (
r"histogram\(_timestamp,\s*'(\d+)\s*"
r"(second|seconds|minute|minutes|hour|hours|day|days)'\)"
)
result = re.sub(pattern, _bucket_us, sql, flags=re.IGNORECASE)
return result, False
def translate_oo_to_duckdb(sql: str) -> tuple[str, bool]:
"""Translate OpenObserve SQL → DuckDB SQL.
Time filtering is handled by a pre-filtered view — this only
replaces the stream placeholder and translates OO functions.
Returns (translated_sql, has_oo_specific_functions).
"""
s = sql.replace("{stream}", STREAM).replace("{stream2}", "logs2")
# NOTE: "{stream2}" → "logs2" (DuckDB view name), while conftest.py
# replaces the same placeholder with STREAM2 (the actual OO stream
# name). Both are correct — each runs in its own execution context.
# Check for OO-specific functions before translation
has_oo = _has_oo_specific_functions(s)
s = re.sub(
r"approx_percentile_cont\(CAST\((\w+)\s+AS\s+FLOAT\),\s*([0-9.]+)\)",
r"approx_quantile(\1::FLOAT, \2)", s, flags=re.IGNORECASE,
)
# regexp_match(field, 'pat') in OO returns array of capture groups.
# array_extract(regexp_match(field, 'pat'), N) → regexp_extract(field, 'pat', N)
s = re.sub(
r"array_extract\(\s*regexp_match\(\s*(\w+)\s*,\s*('(?:[^'\\]|\\.)*')\s*\)\s*,\s*(\d+)\s*\)",
r"regexp_extract(\1, \2, \3)",
s,
flags=re.IGNORECASE,
)
# Any remaining bare regexp_match → regexp_matches
s = re.sub(r"\bregexp_match\s*\(", "regexp_matches(", s, flags=re.IGNORECASE)
s = _replace_re_match(s)
s = _replace_match_all(s)
# re_not_match: replace with NOT regexp_matches before _replace_re_match runs
s = re.sub(
r"re_not_match\s*\(", "NOT regexp_matches(", s, flags=re.IGNORECASE
)
s, _ = _replace_histogram(s) # histogram(_timestamp, 'N unit') → date_bin
s = re.sub(r'\b(pivot)\b', r'pivoted', s, flags=re.IGNORECASE)
s = _replace_str_match_ignore_case(s)
s = _replace_regexp_like(s)
s = _replace_approx_distinct(s)
s = _replace_json_get_str_chained(s)
return s, has_oo
# ── Result-set builder ──────────────────────────────────────────────────────
# Columns OO drops due to LAG dedup bug
_OO_DROPPED_COLS = {"prev_tput", "hours_2_back", "delta_from_prev", "temp_diff"}
# Dynamic columns whose values differ between compute time and test time
_DYNAMIC_COLS = {"_timestamp", "_time"}
_EXCLUDE_COLS = _OO_DROPPED_COLS | _DYNAMIC_COLS
# Queries with known OO-DuckDB divergence — skip cell-by-cell comparison
_SKIP_SQLLOGICTEST = {
"Q016", # ROW_NUMBER tie-breaking with 5 records/query
"Q037", # ROW_NUMBER tie-breaking differs between engines
"Q052", # ROW_NUMBER tie-breaking with 5 records/query
"Q058", # Self-join pairings non-deterministic across engines
"Q072", # FULL OUTER JOIN histogram: NULL handling differs
"Q080", # FTS match_all + histogram: DuckDB LIKE != OO Tantivy
"Q104", # ORDER BY + LIMIT + OFFSET tie with 5 records/query
"Q111", # ROW_NUMBER ties in pagination
"Q117", # STRING_AGG ordering differs between engines
"Q121", # STRING_AGG concatenation order
"Q219", # STRING_AGG DISTINCT without ORDER BY — ordering non-deterministic
"Q127", # UNION across 5 groups: boundary records compete for top-10 spots
"Q141", # NTILE quartile mismatch with 5 records/query
"Q146", # NTILE quartile mismatch with 5 records/query
"Q152", # PERCENT_RANK tie-breaking differs
"Q156", # Multi-CTE with LEFT JOIN self-reference — join pairings non-deterministic
"Q158", # UNION ALL with regexp_match — DuckDB RE2 doesn't handle OO regexp_match well
"Q160", # regexp_match with named capture groups — DuckDB RE2 doesn't support (?<name>...) syntax
"Q161", # spath/unnest/flatten/cast_to_arr — OO-specific array functions
"Q162", # rtrim/ltrim/spath/array_element/cast_to_arr — OO-specific array functions
"Q192", # approx_percentile_cont mismatch with 5 records/query
"Q301", # Hour bucket row count mismatch with 5 records/query
"Q305", # Hour bucket row count mismatch with 5 records/query
"Q453", # Hour bucket integer division mismatch (DataFusion vs DuckDB)
"Q454", # Minute bucket integer division mismatch (DataFusion vs DuckDB)
"Q308", # SELECT * — OO omits NULL-valued columns from JSON
"Q320", # SELECT * — OO omits NULL-valued columns from JSON
"Q145", # ROW_NUMBER tie-breaking with 5 records/query (window)
"Q395", # LAG window row count mismatch with 5 records/query
"Q398", # FIRST_VALUE/LAST_VALUE ROWS BETWEEN row count mismatch with 5 records
}
# Queries that use array_has/cast_to_arr — auto-marked as skip_sqllogictest
# since DuckDB cannot replicate OO's array semantics
_HAS_ARRAY_HAS = set()
# Queries where OO returns fewer columns than DuckDB (e.g. ENT FULL OUTER JOIN
# column aliasing). Skip the per-column existence check in legacy mode so the
# test only validates row-count and ORDER BY, not column presence.
_SKIP_COLUMN_CHECK = {"Q072", "Q308", "Q312", "Q313", "Q318", "Q320", "Q323", "Q395", "Q548"}
def compute_results(con: duckdb.DuckDBPyConnection, q: dict, *, is_histogram: bool = False) -> dict | None:
"""Compute the expected result set for a single query.
Returns the new ``expected`` dict, or None if the query should stay
in legacy assertion mode.
"""
offset = q["time_offset"]
time_start = BASE_TS + offset["start_offset"]
time_end = BASE_TS + offset["end_offset"]
# Create a pre-filtered view so EVERY query (CTEs, subqueries, etc.)
# gets the right records without fragile SQL WHERE injection.
# Views are recreated per query because each query has its own
# time_offset — this is intentional, not overhead.
con.execute(
f"CREATE OR REPLACE VIEW logs AS "
f"SELECT * FROM _logs WHERE _timestamp >= {time_start} AND _timestamp <= {time_end}"
)
# Cross-stream: second stream view uses the same time window because
# both streams share identical timestamp ranges — only field values
# differ (via stream_offset=7). A single time_offset covers both.
con.execute(
f"CREATE OR REPLACE VIEW logs2 AS "
f"SELECT * FROM _logs2 WHERE _timestamp >= {time_start} AND _timestamp <= {time_end}"
)
duck_sql, has_oo = translate_oo_to_duckdb(q["sql"])
# For histogram queries, _time is deterministic (bucketed via date_bin
# with fixed origin). Only _timestamp still varies between runs.
if is_histogram:
exclude_cols = _OO_DROPPED_COLS | {"_timestamp"}
else:
exclude_cols = _EXCLUDE_COLS
try:
rows = con.execute(duck_sql).fetchall()
cols = [desc[0] for desc in con.description]
except Exception as e:
print(f" {q['id']}: DuckDB error — {e}")
return None
# Filter out dynamic/OO-dropped columns
keep_idx = [i for i, c in enumerate(cols) if c not in exclude_cols]
filtered_cols = [cols[i] for i in keep_idx]
filtered_rows = [
[str(row[i]) for i in keep_idx] for row in rows
]
# Histogram: OO serialises bucket timestamps as ISO strings
# (e.g. "2026-06-03T04:00:00"), but DuckDB computes epoch
# microseconds. Convert all columns whose values are epoch-us
# timestamps (> 1e15). Some queries (Q072 FULL OUTER JOIN)
# produce multiple histogram bucket columns.
if is_histogram and filtered_rows:
from datetime import datetime, timezone
for row in filtered_rows:
for j, val in enumerate(row):
if val and val != 'None':
try:
us = int(float(val))
if us > 1_000_000_000_000_000:
dt = datetime.fromtimestamp(
us / 1_000_000, tz=timezone.utc
)
row[j] = dt.strftime('%Y-%m-%dT%H:%M:%S')
except (ValueError, OverflowError):
pass
result = {
"columns": filtered_cols,
"results": filtered_rows,
"results_mode": "rowsort",
}
# Detect empty-aggregate results: DuckDB returns 1 row of NULLs for
# aggregate queries with zero matching rows, but OO returns 0 rows.
# OO also drops NULL-valued columns from JSON hits, so column presence
# and row count are unreliable — skip both.
if filtered_rows and all(
all(v == 'None' for v in row) for row in filtered_rows
):
result = {
"skip_sqllogictest": True,
"skip_row_count": True,
"skip_column_check": True,
"columns": filtered_cols,
}
print(f" {q['id']}: empty-aggregate → skip_sqllogictest")
return result
if len(filtered_rows) == 0:
print(f" {q['id']}: 0 rows ({len(cols)} cols, {len(cols) - len(filtered_cols)} excluded)")
else:
print(f" {q['id']}: {len(filtered_rows)} rows, {len(filtered_cols)} cols (excluded {len(cols) - len(filtered_cols)})")
return result
# ── Main ────────────────────────────────────────────────────────────────────
def main():
print("Generating dataset in DuckDB...")
records1 = build_dataset()
records2 = build_dataset(stream_offset=7)
con = duckdb.connect(":memory:")
for label, records in [("_logs", records1), ("_logs2", records2)]:
jsonl_str = "\n".join(json.dumps(r) for r in records)
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tf:
tf.write(jsonl_str)
tmp_path = tf.name
try:
con.execute(
f"CREATE TABLE {label} AS SELECT * FROM read_json_auto(?)",
[tmp_path],
)
finally:
Path(tmp_path).unlink()
count = con.execute(f"SELECT COUNT(*) FROM {label}").fetchone()[0]
print(f"Loaded {label}: {count} records")
total = con.execute("SELECT COUNT(*) FROM _logs").fetchone()[0]
# Persist the BASE_TS so the test harness uses the same value.
# Without this, a minute-boundary crossing between compute and test
# shifts all histogram bucket timestamps.
override_path = QUERIES_DIR.parent / "base_ts_override.json"
override_path.write_text(json.dumps({"BASE_TS": BASE_TS}))
print(f"Saved BASE_TS override: {BASE_TS}")
updated = 0
skipped = 0
for fp in sorted(QUERIES_DIR.glob("*.json")):
with open(fp) as f:
query_data = json.load(f)
queries = query_data.get("queries")
if queries is None:
print(f" (skipping {fp.name}: no 'queries' key)")
continue
dirty = False
for q in queries:
expected = q.get("expected", {})
# Histogram queries: skip auto-interval, convert explicit-interval
is_histogram = "histogram(" in q["sql"]
if is_histogram:
_, has_auto = _replace_histogram(q["sql"])
if has_auto:
skipped += 1
continue
# Skip Q122 — STRING_AGG DISTINCT ORDER BY: DuckDB requires
# ORDER BY expressions to appear in the aggregate argument
# list for DISTINCT aggregates.
if q["id"] == "Q122":
skipped += 1
continue
# Preserve manually-set metadata from the old expected block
# (compute_results only returns columns+results+results_mode)
old_expected = q.get("expected", {})
new_expected = compute_results(con, q, is_histogram=is_histogram)
if new_expected is None:
skipped += 1
continue
# Merge preserved keys from old expected into new expected
_PRESERVE_KEYS = ("skip_sqllogictest", "skip_row_count",
"skip_column_check",
"skip_without_single_node_opt",
"note", "assertions", "row_count")
for key in _PRESERVE_KEYS:
if key in old_expected:
new_expected[key] = old_expected[key]
# Auto-mark OO-specific array function queries for skip_sqllogictest
# (only if not already manually set)
if "skip_sqllogictest" not in new_expected:
_, has_oo = translate_oo_to_duckdb(q["sql"])
if has_oo:
new_expected["skip_sqllogictest"] = True
if q["id"] not in _SKIP_SQLLOGICTEST:
_SKIP_SQLLOGICTEST.add(q["id"])
# Mark known-divergent queries for skip-sqllogictest fallback
if q["id"] in _SKIP_SQLLOGICTEST and "skip_sqllogictest" not in new_expected:
new_expected["skip_sqllogictest"] = True
# Skip per-column existence check for queries where OO returns
# fewer columns than DuckDB (e.g. FULL OUTER JOIN aliasing in ENT)
if q["id"] in _SKIP_COLUMN_CHECK:
new_expected["skip_column_check"] = True
# Replace the expected block
q["expected"] = new_expected
dirty = True
if dirty:
with open(fp, "w") as f:
json.dump(query_data, f, indent=2)
f.write("\n")
updated += 1
print(f"\nUpdated {updated} query files")
print(f"Skipped {skipped} (auto-interval histogram + Q122 + errors)")
con.close()
if __name__ == "__main__":
main()