Files
openobserve/tests/api-testing/tests/vortex/test_api_format.py
T
Neha PandClaude Sonnet 4.6 146d5e7612 feat(tests): vortex file format test suite (scenarios 1–83) (#12472)
## Summary

- Adds **239 new tests** covering the vortex columnar file format across
78 scenarios from the test plan
- **`tests/api-testing/tests/vortex/`** — 9 test modules (83 tests):
format conversion, schema evolution, data integrity, FTS, downsampling,
API format awareness, server config, and performance/stress
- **`tests/api-testing/tests/query_agent/test_3_vortex.py`** — 156
tests: the full 155-query SQL correctness suite + scenario 39
(self-join) run post-flush against vortex files
- **`api-testing.yml`** — adds `--ignore=tests/vortex` to the OSS
default-set run; vortex tests require the enterprise binary with
`ZO_FILE_FORMAT=vortex` and are gated to enterprise CI only

## Test plan coverage

| Module | Scenarios |
|--------|-----------|
| `test_format_conversion.py` | 1–13, 7, 8, 9, 10 |
| `test_schema_evolution.py` | 14–25 |
| `test_data_integrity.py` | 55–62 |
| `test_fts.py` | 51–54 |
| `test_downsampling.py` | 47–50 |
| `test_api_format.py` | 71–73 |
| `test_config.py` | 66–67 |
| `test_performance.py` | 74–78 |
| `test_3_vortex.py` | 26–46 (155 queries + JOIN) |

## Enterprise CI

Two dedicated jobs added in the companion `openobserve/o2-enterprise` PR
on the same branch:
- **`api_query_agent_vortex`** — runs `test_3_vortex.py` with
`ZO_FILE_FORMAT=vortex`
- **`api_vortex_format_tests`** — runs the full `tests/vortex/` suite
with 4-phase server lifecycle (parquet→vortex→parquet→vortex+compaction)

OSS CI is unchanged except for the ignore rule — no vortex tests run
against the OSS binary.

## Test checklist

- [ ] `tests/vortex/` collects cleanly: `83 tests collected`
- [ ] `tests/query_agent/test_3_vortex.py` collects cleanly: `156 tests
collected`
- [ ] Enterprise CI `api_query_agent_vortex` job passes
- [ ] Enterprise CI `api_vortex_format_tests` job passes

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 13:57:17 +00:00

203 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Vortex API-level format awareness tests (Section 8, scenarios 71–73).
Verifies that the HTTP API correctly surfaces stream metadata, schema, and
query results regardless of whether underlying files are .parquet or .vortex.
Key findings from API probing:
- Schema lives in GET /api/{org}/streams?type=logs&stream_name={name}
under the `schema` key — there is no separate /schema endpoint.
- Result caching: `result_cache_ratio` in _search responses is 0 on first
query, 100 on repeat identical queries.
Enterprise CI only — do not add to OSS api-testing.yml.
Test plan coverage: scenarios 71–73 (Section 8 — API-Level Format Awareness).
"""
from __future__ import annotations
import time
from datetime import datetime, timezone, timedelta
import pytest
from support.factories import search_payload
from .conftest import SESSION_ID, flush_and_wait, ingest, wait_for_stream_stats
_BASE = f"va_{SESSION_ID}" # vortex_api
def _stream(suffix: str) -> str:
return f"{_BASE}_{suffix}"
def _ts(offset_ms: int = 0) -> int:
return int(time.time() * 1_000_000) + offset_ms * 1_000
def _wide_payload(stream: str, sql: str, size: int = 20) -> dict:
now = datetime.now(timezone.utc)
return search_payload(
sql,
start_time=int((now - timedelta(weeks=4)).timestamp() * 1_000_000),
end_time=int((now + timedelta(hours=1)).timestamp() * 1_000_000),
size=size,
)
def _get_stream_entry(client, stream: str) -> dict:
"""Fetch the stream listing entry for *stream*. Returns {} if not found."""
resp = client.get(f"streams?type=logs&stream_name={stream}")
assert resp.status_code == 200, f"streams listing failed: {resp.status_code}"
return next(
(s for s in resp.json().get("list", []) if s["name"] == stream),
{},
)
# ─── Scenario 71: Stream listing ──────────────────────────────────────────────
class TestStreamListing:
"""Scenario 71: stream listing returns correct stats after vortex ingest+flush."""
STREAM = _stream("listing")
N = 15
def test_71_stream_appears_in_listing(self, client):
records = [
{"_timestamp": _ts(i * 100), "host": f"h{i}", "level": "info", "idx": i}
for i in range(self.N)
]
ingest(client, self.STREAM, records)
flush_and_wait(client, self.STREAM, expected=self.N)
entry = _get_stream_entry(client, self.STREAM)
assert entry, f"stream {self.STREAM!r} not found in listing"
assert entry["name"] == self.STREAM
assert entry["stream_type"] == "logs"
def test_71_stats_structure_correct(self, client):
"""Stream stats have the right shape after flush.
doc_num/storage_size only update after compaction (server-side schedule).
We assert structure and created_at; size/count fields require compaction.
"""
entry = _get_stream_entry(client, self.STREAM)
assert entry, f"stream {self.STREAM!r} not found"
stats = entry.get("stats", {})
assert int(stats.get("created_at", 0)) > 0, "created_at must be set"
for key in ("doc_num", "file_num"):
assert int(stats.get(key, 0)) >= 0, f"{key} must be >= 0"
for key in ("storage_size", "compressed_size"):
assert float(stats.get(key, 0)) >= 0, f"{key} must be >= 0"
def test_71_total_fields_non_negative(self, client):
entry = _get_stream_entry(client, self.STREAM)
assert entry, f"stream {self.STREAM!r} not found"
assert int(entry.get("total_fields", 0)) >= 0
# ─── Scenario 72: Schema API ───────────────────────────────────────────────────
class TestSchemaAPI:
"""Scenario 72: schema returned via the stream listing is self-consistent
for a stream containing data (whether parquet or vortex on disk).
Note: there is no separate /schema HTTP endpoint — schema is embedded in the
streams listing response under the `schema` key.
"""
STREAM = _stream("schema")
FIELDS = ["env", "region", "service", "latency_ms", "success"]
def test_72_schema_is_present_after_flush(self, client):
records = [
{
"_timestamp": _ts(i * 100),
"env": "prod",
"region": "us-east-1",
"service": f"svc-{i % 3}",
"latency_ms": i * 10,
"success": i % 2 == 0,
}
for i in range(10)
]
ingest(client, self.STREAM, records)
flush_and_wait(client, self.STREAM, expected=10)
entry = _get_stream_entry(client, self.STREAM)
assert entry, f"stream {self.STREAM!r} not found"
schema = entry.get("schema", [])
assert isinstance(schema, list), f"schema should be a list, got {type(schema)}"
def test_72_schema_fields_have_name_and_type(self, client):
entry = _get_stream_entry(client, self.STREAM)
schema = entry.get("schema", [])
if not schema:
pytest.skip("schema not yet materialized — may need more data or a compaction cycle")
for field in schema:
assert "name" in field, f"schema field missing 'name': {field}"
assert "type" in field, f"schema field missing 'type': {field}"
def test_72_schema_covers_ingested_fields(self, client):
entry = _get_stream_entry(client, self.STREAM)
schema = entry.get("schema", [])
if not schema:
pytest.skip("schema not yet materialized")
schema_names = {f["name"] for f in schema}
for expected_field in self.FIELDS:
assert expected_field in schema_names, \
f"ingested field '{expected_field}' missing from schema: {schema_names}"
# ─── Scenario 73: Cache behavior ──────────────────────────────────────────────
class TestCacheBehavior:
"""Scenario 73: identical queries against a mixed-format stream hit the result
cache on repeat, returning identical results both times."""
STREAM = _stream("cache")
N = 12
def _run_search(self, client) -> tuple[list, int]:
"""Returns (hits, result_cache_ratio)."""
payload = _wide_payload(
self.STREAM,
f'SELECT host, idx FROM "{self.STREAM}" ORDER BY idx ASC',
size=self.N + 5,
)
resp = client.post("_search?type=logs", json=payload)
assert resp.status_code == 200, f"search failed: {resp.status_code}"
data = resp.json()
return data["hits"], int(data.get("result_cache_ratio", 0))
def test_73_results_identical_on_repeat_query(self, client):
records = [
{"_timestamp": _ts(i * 100), "host": f"host-{i}", "idx": i}
for i in range(self.N)
]
ingest(client, self.STREAM, records)
flush_and_wait(client, self.STREAM, expected=self.N)
hits1, ratio1 = self._run_search(client)
hits2, ratio2 = self._run_search(client)
assert len(hits1) == len(hits2), \
f"hit count changed between queries: {len(hits1)} vs {len(hits2)}"
for i, (h1, h2) in enumerate(zip(hits1, hits2)):
assert h1 == h2, f"row {i} differs between queries: {h1!r} vs {h2!r}"
def test_73_second_query_uses_result_cache(self, client):
# First call is a cache miss (ratio may be 0); second should be a cache hit.
_, ratio1 = self._run_search(client)
_, ratio2 = self._run_search(client)
assert ratio2 >= ratio1, \
f"result_cache_ratio should not decrease on repeat: {ratio1} → {ratio2}"