blob: 0c7830aac3547e5eabf68e1c667a53073a04c508 [file]
from __future__ import annotations
import json
import logging
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from iotdb_mcp_server.config import Config # noqa: E402
from iotdb_mcp_server.result_store import (
FileResultStore,
ResultStoreSettings,
result_store_from_export_path,
) # noqa: E402
from iotdb_mcp_server.services.json_response import (
csv_result_payload_response,
) # noqa: E402
from iotdb_mcp_server.services.results import register_result_store_tools # noqa: E402
class FakeMCP:
def __init__(self) -> None:
self.tools = {}
def tool(self):
def decorator(fn):
self.tools[fn.__name__] = fn
return fn
return decorator
class FileResultStoreTest(unittest.TestCase):
def test_failed_manifest_write_removes_partial_result_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=2,
page_size_rows=2,
max_page_rows=10,
shard_rows=10,
shard_bytes=1024,
),
)
with patch.object(
Path,
"write_text",
side_effect=OSError("disk full"),
), self.assertRaisesRegex(OSError, "disk full"):
store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=["0,0"],
owner_session_id="ses_manifest_failure",
)
session_dir = Path(tmp) / "sessions" / "ses_manifest_failure"
self.assertEqual(list(session_dir.glob("res_*")), [])
def test_failed_row_iteration_removes_partial_result_directory(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=2,
page_size_rows=2,
max_page_rows=10,
shard_rows=10,
shard_bytes=1024,
),
)
def failing_rows():
yield "0,0"
raise RuntimeError("row generation failed")
with self.assertRaisesRegex(RuntimeError, "row generation failed"):
store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=failing_rows(),
owner_session_id="ses_failure",
)
session_dir = Path(tmp) / "sessions" / "ses_failure"
self.assertEqual(list(session_dir.glob("res_*")), [])
self.assertEqual(
store.list_results("ses_failure", limit=10)["result_count"],
0,
)
def test_write_and_read_pages_across_shards(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=2,
page_size_rows=2,
max_page_rows=10,
shard_rows=2,
shard_bytes=1024,
),
)
stored = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=[f"{index},{index * 10}" for index in range(5)],
source={"query_sql": "SELECT s0 FROM root.sg.d"},
owner_session_id="ses_test",
)
self.assertEqual(stored.row_count, 5)
self.assertEqual(stored.owner_session_id, "ses_test")
self.assertEqual(stored.preview_rows, ["0,0", "1,10"])
self.assertEqual(stored.shard_count, 3)
self.assertIn("/sessions/ses_test/", stored.manifest_path)
first = store.read_page(
stored.result_id,
offset=0,
limit=2,
owner_session_id="ses_test",
)
self.assertEqual(first["rows"], ["0,0", "1,10"])
self.assertEqual(first["owner_session_id"], "ses_test")
self.assertEqual(first["next_cursor"], "2")
self.assertTrue(first["has_more"])
second = store.read_page(
stored.result_id,
offset=int(first["next_cursor"]),
limit=3,
owner_session_id="ses_test",
)
self.assertEqual(second["rows"], ["2,20", "3,30", "4,40"])
self.assertIsNone(second["next_cursor"])
self.assertFalse(second["has_more"])
def test_csv_result_payload_response_returns_preview_and_result_id(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=2,
page_size_rows=2,
max_page_rows=10,
shard_rows=10,
shard_bytes=1024,
),
)
response = csv_result_payload_response(
"select_query",
["Time", "root.sg.d.s0"],
[f"{index},{index * 10}" for index in range(4)],
result_store=store,
source={"query_sql": "SELECT s0 FROM root.sg.d"},
owner_session_id="ses_payload",
)
self.assertEqual(len(response), 1)
obj = json.loads(response[0].text)
payload = obj["payload"]
self.assertEqual(payload["row_count"], 4)
self.assertEqual(payload["inline_row_count"], 2)
self.assertTrue(payload["inline_truncated"])
self.assertEqual(payload["rows"], ["0,0", "1,10"])
self.assertEqual(payload["next_cursor"], "2")
self.assertEqual(payload["owner_session_id"], "ses_payload")
self.assertEqual(payload["result_store"]["owner_session_id"], "ses_payload")
self.assertEqual(payload["result_store"]["page_tool"], "read_result_page")
page = store.read_page(
payload["result_id"],
offset=2,
limit=10,
owner_session_id="ses_payload",
)
self.assertEqual(page["rows"], ["2,20", "3,30"])
def test_empty_result_is_pageable(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=2,
page_size_rows=2,
max_page_rows=10,
shard_rows=10,
shard_bytes=1024,
),
)
stored = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=[],
)
self.assertEqual(stored.row_count, 0)
page = store.read_page(stored.result_id)
self.assertEqual(page["rows"], [])
self.assertEqual(page["owner_session_id"], "standalone")
self.assertIsNone(page["next_cursor"])
self.assertFalse(page["has_more"])
def test_read_pages_returns_multiple_pages(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=1,
page_size_rows=2,
max_page_rows=10,
shard_rows=2,
shard_bytes=1024,
),
)
first = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=[f"{index},{index * 10}" for index in range(4)],
owner_session_id="ses_pages",
)
second = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s1"],
rows=[f"{index},{index * 100}" for index in range(3)],
owner_session_id="ses_pages",
)
payload = store.read_pages(
[
{"result_id": first.result_id, "limit": 2},
{"result_id": second.result_id, "cursor": "1", "limit": 2},
],
owner_session_id="ses_pages",
max_total_rows=10,
)
self.assertEqual(payload["succeeded"], 2)
self.assertEqual(payload["failed"], 0)
self.assertEqual(payload["returned_rows"], 4)
self.assertEqual(payload["pages"][0]["rows"], ["0,0", "1,10"])
self.assertEqual(payload["pages"][0]["next_cursor"], "2")
self.assertEqual(payload["pages"][1]["rows"], ["1,100", "2,200"])
self.assertFalse(payload["pages"][1]["has_more"])
def test_read_pages_returns_item_errors_and_skips_after_abort(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=1,
page_size_rows=2,
max_page_rows=10,
shard_rows=10,
shard_bytes=1024,
),
)
live = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=["0,0"],
owner_session_id="ses_pages_error",
)
missing = "res_20260706T000000Z_0123456789abcdef"
payload = store.read_pages(
[
{"result_id": missing},
{"result_id": live.result_id},
],
owner_session_id="ses_pages_error",
continue_on_error=False,
)
self.assertEqual(payload["succeeded"], 0)
self.assertEqual(payload["failed"], 1)
self.assertEqual(payload["skipped"], 1)
self.assertEqual(payload["pages"][0]["status"], "error")
self.assertEqual(payload["pages"][1]["status"], "skipped")
def test_read_result_pages_tool_returns_text_for_success_pages(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
cfg = Config(
host="127.0.0.1",
port=6667,
user="root",
password="",
database="",
sql_dialect="tree",
timezone="Asia/Shanghai",
export_path=tmp,
)
store = result_store_from_export_path(tmp)
stored = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=["0,0", "1,10", "2,20"],
owner_session_id="ses_tool_pages",
)
fake_mcp = FakeMCP()
register_result_store_tools(
fake_mcp,
cfg,
logging.getLogger("test_read_result_pages_tool"),
)
import asyncio
response = asyncio.run(
fake_mcp.tools["read_result_pages"](
pages=[
{"result_id": stored.result_id, "limit": 2},
{"result_id": stored.result_id, "cursor": "2", "limit": 2},
],
owner_session_id="ses_tool_pages",
default_limit=2,
)
)
payload = json.loads(response[-1].text)["payload"]
self.assertEqual(payload["succeeded"], 2)
self.assertEqual(payload["returned_rows"], 3)
self.assertEqual(payload["pages"][0]["inline_row_count"], 2)
self.assertTrue(payload["pages"][0]["inline_truncated"])
self.assertIn("Time,root.sg.d.s0\n0,0\n1,10", payload["pages"][0]["text"])
def test_session_lru_quota_evicts_oldest_result(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=1,
page_size_rows=2,
max_page_rows=10,
shard_rows=10,
shard_bytes=1024,
ttl_seconds=0,
max_session_results=2,
max_session_bytes=1024 * 1024,
max_global_results=100,
max_global_bytes=1024 * 1024,
),
)
first = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=["0,0"],
owner_session_id="ses_quota",
)
second = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=["1,10"],
owner_session_id="ses_quota",
)
third = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=["2,20"],
owner_session_id="ses_quota",
)
listing = store.list_results("ses_quota", limit=10)
result_ids = {item["result_id"] for item in listing["results"]}
self.assertEqual(listing["result_count"], 2)
self.assertNotIn(first.result_id, result_ids)
self.assertIn(second.result_id, result_ids)
self.assertIn(third.result_id, result_ids)
def test_ttl_cleanup_removes_expired_results(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=1,
page_size_rows=2,
max_page_rows=10,
shard_rows=10,
shard_bytes=1024,
ttl_seconds=0,
max_session_results=100,
max_session_bytes=1024 * 1024,
max_global_results=100,
max_global_bytes=1024 * 1024,
),
)
expired = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=["0,0"],
owner_session_id="ses_ttl",
)
live = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=["1,10"],
owner_session_id="ses_ttl",
)
manifest_path = Path(expired.manifest_path)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["last_accessed_at"] = (
datetime.now(timezone.utc) - timedelta(seconds=30)
).isoformat()
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
summary = store.cleanup(owner_session_id="ses_ttl", ttl_seconds=1)
result_ids = {
item["result_id"]
for item in store.list_results("ses_ttl", limit=10)["results"]
}
self.assertEqual(summary["deleted_results"], 1)
self.assertNotIn(expired.result_id, result_ids)
self.assertIn(live.result_id, result_ids)
def test_single_oversized_result_is_retained_for_paging(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
store = FileResultStore(
tmp,
ResultStoreSettings(
preview_rows=1,
page_size_rows=2,
max_page_rows=10,
shard_rows=10,
shard_bytes=1024,
ttl_seconds=0,
max_session_results=100,
max_session_bytes=1,
max_global_results=100,
max_global_bytes=1,
),
)
stored = store.write_csv_result(
tool="select_query",
columns=["Time", "root.sg.d.s0"],
rows=["0," + "x" * 200],
owner_session_id="ses_oversized",
)
page = store.read_page(
stored.result_id,
owner_session_id="ses_oversized",
)
self.assertEqual(page["returned_rows"], 1)
self.assertGreater(
store.list_results("ses_oversized")["storage_bytes"],
1,
)
if __name__ == "__main__":
unittest.main()