From 3f1b1220550ee3a7753f4850762b2340a408a51c Mon Sep 17 00:00:00 2001 From: dingben Date: Thu, 13 Aug 2026 14:36:31 +0800 Subject: [PATCH 1/2] fix(vikingdb): normalize all date_time range filters in API key client OpenViking compiles TimeRange down to the internal `range` DSL, but the commercial VikingDB data plane (Bearer API-key auth) expects `time_range` for date_time fields and `range` only for numeric fields. The API-key client does not run the local engine's filter conversion, so `range` nodes on date_time fields were sent verbatim and mis-handled. Normalize `range` -> `time_range` for every schema date_time field by reusing the canonical VALID_TIME_FIELDS constant, covering both `created_at` and `updated_at` instead of hardcoding a single field name. Numeric `range` nodes and nested boolean filter structure are preserved, and filters already emitted as `time_range` pass through unchanged. Only the request body `filter` is rewritten; upsert/update data is untouched. Add regression tests covering the converted created_at/updated_at date filters, an unchanged numeric filter, and time_range idempotency. Co-authored-by: TRAE CLI --- .../volcengine_api_key_collection.py | 21 ++++ tests/storage/test_volcengine_clients.py | 115 ++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/openviking/storage/vectordb/collection/volcengine_api_key_collection.py b/openviking/storage/vectordb/collection/volcengine_api_key_collection.py index 2ca64f0697..89ffbdfe44 100644 --- a/openviking/storage/vectordb/collection/volcengine_api_key_collection.py +++ b/openviking/storage/vectordb/collection/volcengine_api_key_collection.py @@ -19,6 +19,7 @@ ClientForDataApi, ClientForDataApiWithApiKey, ) +from openviking.utils.search_filters import VALID_TIME_FIELDS from openviking_cli.utils.logger import default_logger as logger @@ -150,8 +151,28 @@ def _sanitize_list_payload(cls, obj: List[Any]) -> List[Any]: sanitized_list.append(y) return sanitized_list + @classmethod + def _normalize_date_time_filter(cls, obj: Any) -> Any: + """Rewrite ``range`` nodes on date_time fields to VikingDB ``time_range``. + + OpenViking compiles ``TimeRange`` down to the internal ``range`` DSL, but the + commercial data-plane expects ``time_range`` for date_time fields and ``range`` + only for numeric fields. Numeric ``range`` nodes are left untouched. + """ + if isinstance(obj, list): + return [cls._normalize_date_time_filter(item) for item in obj] + if not isinstance(obj, dict): + return obj + + normalized = {key: cls._normalize_date_time_filter(value) for key, value in obj.items()} + if normalized.get("op") == "range" and normalized.get("field") in VALID_TIME_FIELDS: + normalized["op"] = "time_range" + return normalized + def _data_post(self, path: str, data: Dict[str, Any]): safe_data = self._sanitize_payload(data) + if isinstance(safe_data, dict) and "filter" in safe_data: + safe_data["filter"] = self._normalize_date_time_filter(safe_data["filter"]) response = self.data_client.do_req("POST", path, req_body=safe_data) if response.status_code != 200: raise self._build_response_error(response, path) diff --git a/tests/storage/test_volcengine_clients.py b/tests/storage/test_volcengine_clients.py index 4e53b155bf..315513db72 100644 --- a/tests/storage/test_volcengine_clients.py +++ b/tests/storage/test_volcengine_clients.py @@ -373,6 +373,121 @@ def _fake_do_req(method, req_path=None, req_params=None, req_body=None): } +def test_volcengine_api_key_collection_uses_date_time_filter_operator(monkeypatch): + captured = {} + + class _Response: + status_code = 200 + + @staticmethod + def json(): + return {"result": {"agg": {"_total": 1}}} + + from openviking.storage.vectordb.collection.volcengine_api_key_collection import ( + VolcengineApiKeyCollection, + ) + + collection = VolcengineApiKeyCollection( + api_key="vk-test-token", + region="cn-beijing", + meta_data={"ProjectName": "default", "CollectionName": "context", "IndexName": "default"}, + ) + + def _fake_do_req(method, req_path=None, req_params=None, req_body=None): + captured["path"] = req_path + captured["req_body"] = req_body + return _Response() + + monkeypatch.setattr(collection.data_client, "do_req", _fake_do_req) + + # Both date_time fields (created_at, updated_at) must be normalized to + # time_range, while numeric range nodes are left untouched. + collection.aggregate_data( + index_name="default", + filters={ + "op": "and", + "conds": [ + { + "op": "range", + "field": "created_at", + "gte": "2026-08-10T00:00:00+00:00", + "lt": "2026-08-11T00:00:00+00:00", + }, + { + "op": "range", + "field": "updated_at", + "gte": "2026-08-10T00:00:00+00:00", + }, + {"op": "range", "field": "level", "gte": 1}, + ], + }, + ) + + assert captured["path"] == "/api/vikingdb/data/agg" + assert captured["req_body"]["filter"] == { + "op": "and", + "conds": [ + { + "op": "time_range", + "field": "created_at", + "gte": "2026-08-10T00:00:00+00:00", + "lt": "2026-08-11T00:00:00+00:00", + }, + { + "op": "time_range", + "field": "updated_at", + "gte": "2026-08-10T00:00:00+00:00", + }, + {"op": "range", "field": "level", "gte": 1}, + ], + } + + +def test_volcengine_api_key_collection_date_time_filter_is_idempotent(monkeypatch): + captured = {} + + class _Response: + status_code = 200 + + @staticmethod + def json(): + return {"result": {"data": []}} + + from openviking.storage.vectordb.collection.volcengine_api_key_collection import ( + VolcengineApiKeyCollection, + ) + + collection = VolcengineApiKeyCollection( + api_key="vk-test-token", + region="cn-beijing", + meta_data={"ProjectName": "default", "CollectionName": "context", "IndexName": "default"}, + ) + + def _fake_do_req(method, req_path=None, req_params=None, req_body=None): + captured["req_body"] = req_body + return _Response() + + monkeypatch.setattr(collection.data_client, "do_req", _fake_do_req) + + # Filters already emitted as time_range (e.g. from merge_time_filter) must + # pass through unchanged. + collection.search_by_scalar( + index_name="default", + field="created_at", + filters={ + "op": "time_range", + "field": "updated_at", + "gte": "2026-08-10T00:00:00+00:00", + }, + ) + + assert captured["req_body"]["filter"] == { + "op": "time_range", + "field": "updated_at", + "gte": "2026-08-10T00:00:00+00:00", + } + + def test_volcengine_adapter_update_data_returns_ids(): adapter = VolcengineCollectionAdapter( ak="test-ak", From a638d3d940e34a5dbe1be18aa41d1031261a1f8a Mon Sep 17 00:00:00 2001 From: dingben Date: Thu, 13 Aug 2026 15:20:45 +0800 Subject: [PATCH 2/2] fix(vikingdb): normalize date_time filters in AK/SK client The API-key client already rewrites `range` filter nodes on date_time fields to VikingDB's `time_range` operator, but the AK/SK-signed `VolcengineCollection` shares the same commercial data-plane endpoints and had the identical latent bug: `TimeRange` expressions compile down to the internal `range` DSL, which the commercial API only accepts for numeric fields. Mirror the API-key fix in `VolcengineCollection._data_post` so both auth modes normalize `range` -> `time_range` for `created_at`/`updated_at` while leaving numeric `range` nodes untouched. Add AK/SK coverage for both date_time fields and for idempotency of already-`time_range` input. Co-authored-by: TRAE CLI --- .../collection/volcengine_collection.py | 21 ++++ tests/storage/test_volcengine_clients.py | 109 ++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/openviking/storage/vectordb/collection/volcengine_collection.py b/openviking/storage/vectordb/collection/volcengine_collection.py index 39e2afe606..20196d2bf0 100644 --- a/openviking/storage/vectordb/collection/volcengine_collection.py +++ b/openviking/storage/vectordb/collection/volcengine_collection.py @@ -18,6 +18,7 @@ ClientForConsoleApi, ClientForDataApi, ) +from openviking.utils.search_filters import VALID_TIME_FIELDS from openviking_cli.utils.logger import default_logger as logger @@ -255,9 +256,29 @@ def _sanitize_list_payload(cls, obj: List[Any]) -> List[Any]: sanitized_list.append(y) return sanitized_list + @classmethod + def _normalize_date_time_filter(cls, obj: Any) -> Any: + """Rewrite ``range`` nodes on date_time fields to VikingDB ``time_range``. + + OpenViking compiles ``TimeRange`` down to the internal ``range`` DSL, but the + commercial data-plane expects ``time_range`` for date_time fields and ``range`` + only for numeric fields. Numeric ``range`` nodes are left untouched. + """ + if isinstance(obj, list): + return [cls._normalize_date_time_filter(item) for item in obj] + if not isinstance(obj, dict): + return obj + + normalized = {key: cls._normalize_date_time_filter(value) for key, value in obj.items()} + if normalized.get("op") == "range" and normalized.get("field") in VALID_TIME_FIELDS: + normalized["op"] = "time_range" + return normalized + def _data_post(self, path: str, data: Dict[str, Any]): # Centralized sanitization at the request exit, covering all data API inputs safe_data = self._sanitize_payload(data) + if isinstance(safe_data, dict) and "filter" in safe_data: + safe_data["filter"] = self._normalize_date_time_filter(safe_data["filter"]) response = self.data_client.do_req("POST", path, req_body=safe_data) if response.status_code != 200: logger.error(f"Request to {path} failed: {response.text}") diff --git a/tests/storage/test_volcengine_clients.py b/tests/storage/test_volcengine_clients.py index 315513db72..20d98a0f1e 100644 --- a/tests/storage/test_volcengine_clients.py +++ b/tests/storage/test_volcengine_clients.py @@ -293,6 +293,115 @@ def _fake_do_req(method, path=None, req_params=None, req_body=None): } +def test_volcengine_collection_uses_date_time_filter_operator(monkeypatch): + captured = {} + + class _Response: + status_code = 200 + + @staticmethod + def json(): + return {"result": {"agg": {"_total": 1}}} + + collection = VolcengineCollection( + ak="test-ak", + sk="test-sk", + region="cn-beijing", + meta_data={"ProjectName": "default", "CollectionName": "context"}, + ) + + def _fake_do_req(method, path=None, req_params=None, req_body=None): + captured["path"] = path + captured["req_body"] = req_body + return _Response() + + monkeypatch.setattr(collection.data_client, "do_req", _fake_do_req) + + # Both date_time fields (created_at, updated_at) must be normalized to + # time_range, while numeric range nodes are left untouched. + collection.aggregate_data( + index_name="default", + filters={ + "op": "and", + "conds": [ + { + "op": "range", + "field": "created_at", + "gte": "2026-08-10T00:00:00+00:00", + "lt": "2026-08-11T00:00:00+00:00", + }, + { + "op": "range", + "field": "updated_at", + "gte": "2026-08-10T00:00:00+00:00", + }, + {"op": "range", "field": "level", "gte": 1}, + ], + }, + ) + + assert captured["path"] == "/api/vikingdb/data/agg" + assert captured["req_body"]["filter"] == { + "op": "and", + "conds": [ + { + "op": "time_range", + "field": "created_at", + "gte": "2026-08-10T00:00:00+00:00", + "lt": "2026-08-11T00:00:00+00:00", + }, + { + "op": "time_range", + "field": "updated_at", + "gte": "2026-08-10T00:00:00+00:00", + }, + {"op": "range", "field": "level", "gte": 1}, + ], + } + + +def test_volcengine_collection_date_time_filter_is_idempotent(monkeypatch): + captured = {} + + class _Response: + status_code = 200 + + @staticmethod + def json(): + return {"result": {"data": []}} + + collection = VolcengineCollection( + ak="test-ak", + sk="test-sk", + region="cn-beijing", + meta_data={"ProjectName": "default", "CollectionName": "context"}, + ) + + def _fake_do_req(method, path=None, req_params=None, req_body=None): + captured["req_body"] = req_body + return _Response() + + monkeypatch.setattr(collection.data_client, "do_req", _fake_do_req) + + # Filters already emitted as time_range (e.g. from merge_time_filter) must + # pass through unchanged. + collection.search_by_scalar( + index_name="default", + field="created_at", + filters={ + "op": "time_range", + "field": "updated_at", + "gte": "2026-08-10T00:00:00+00:00", + }, + ) + + assert captured["req_body"]["filter"] == { + "op": "time_range", + "field": "updated_at", + "gte": "2026-08-10T00:00:00+00:00", + } + + def test_volcengine_api_key_collection_update_data_posts_to_update_endpoint(monkeypatch): captured = {}