Connector calls, semantic rules, and DuckDB memory keep long runs stable, and stream retry stops chats from stalling. Co-authored-by: Cursor <cursoragent@cursor.com>
648 lines
22 KiB
Python
648 lines
22 KiB
Python
"""Memory lifecycle: queues, ReAct scratch, adapter cache, DuckDB caps."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import gc
|
|
import json
|
|
import tracemalloc
|
|
from types import SimpleNamespace
|
|
|
|
import psutil
|
|
import pytest
|
|
|
|
from kamidso.engine.app.openapi.api_v1.react_runtime import (
|
|
REACT_AGENT_MEMORY_CACHE,
|
|
REACT_CONVERSATION_LOCKS,
|
|
acquire_conversation_run,
|
|
conversation_lock_refcount,
|
|
end_react_turn,
|
|
release_conversation_run,
|
|
)
|
|
from kamidso.engine.core.agent.core.memory.gpts.base import GptsMessage
|
|
from kamidso.engine.core.agent.core.memory.gpts.gpts_memory import GptsMemory
|
|
from kamidso.engine.core.datasource import duckdb_memory as duck_mem
|
|
from kamidso.integrations.engine import events as adapter_events
|
|
from kamidso.integrations.engine.engine import KamidsoEngine
|
|
from kamidso.integrations.engine.events import (
|
|
adapter_cache_size,
|
|
adapter_run_is_active,
|
|
begin_adapter_run,
|
|
map_engine_event,
|
|
release_adapter_run,
|
|
sweep_adapter_cache,
|
|
)
|
|
|
|
|
|
def _message(
|
|
conv_id: str,
|
|
content: str,
|
|
*,
|
|
rounds: int = 0,
|
|
sender: str = "ReAct",
|
|
receiver: str = "Human",
|
|
action_report: str | None = None,
|
|
) -> GptsMessage:
|
|
return GptsMessage(
|
|
conv_id=conv_id,
|
|
sender=sender,
|
|
receiver=receiver,
|
|
role="ai" if sender != "Human" else "human",
|
|
content=content,
|
|
rounds=rounds,
|
|
app_code="react_agent",
|
|
app_name="ReAct",
|
|
action_report=action_report,
|
|
)
|
|
|
|
|
|
class _Store:
|
|
def __init__(self) -> None:
|
|
self.rows: list[GptsMessage] = []
|
|
self.deleted: list[str] = []
|
|
|
|
def append(self, message: GptsMessage) -> None:
|
|
self.rows.append(message)
|
|
|
|
def get_by_conv_id(self, conv_id: str) -> list[GptsMessage]:
|
|
return [row for row in self.rows if row.conv_id == conv_id]
|
|
|
|
def get_by_agent(self, conv_id: str, agent: str) -> list[GptsMessage]:
|
|
return [
|
|
row
|
|
for row in self.rows
|
|
if row.conv_id == conv_id and (row.sender == agent or row.receiver == agent)
|
|
]
|
|
|
|
def get_between_agents(self, conv_id: str, agent1: str, agent2: str, current_goal: str | None = None):
|
|
del conv_id, agent1, agent2, current_goal
|
|
return []
|
|
|
|
def get_last_message(self, conv_id: str) -> GptsMessage | None:
|
|
rows = self.get_by_conv_id(conv_id)
|
|
return rows[-1] if rows else None
|
|
|
|
def delete_by_conv_id(self, conv_id: str) -> None:
|
|
self.deleted.append(conv_id)
|
|
|
|
|
|
class _Plans:
|
|
def __init__(self) -> None:
|
|
self.removed: list[str] = []
|
|
|
|
def remove_by_conv_id(self, conv_id: str) -> None:
|
|
self.removed.append(conv_id)
|
|
|
|
|
|
class _LatestStore(_Store):
|
|
"""Keeps one row so a repeated-load test does not retain every payload."""
|
|
|
|
def append(self, message: GptsMessage) -> None:
|
|
self.rows = [message]
|
|
|
|
|
|
def _memory(store: _Store | None = None) -> tuple[GptsMemory, _Store, _Plans]:
|
|
store = store or _Store()
|
|
plans = _Plans()
|
|
return GptsMemory(plans_memory=plans, message_memory=store), store, plans
|
|
|
|
|
|
def _reset_runtime() -> None:
|
|
REACT_AGENT_MEMORY_CACHE.clear()
|
|
REACT_CONVERSATION_LOCKS.clear()
|
|
from kamidso.engine.app.openapi.api_v1 import react_runtime
|
|
|
|
react_runtime._LOCK_REFS.clear()
|
|
|
|
|
|
def test_vis_off_without_reader_does_not_queue_history_snapshot():
|
|
async def _run() -> None:
|
|
memory, store, plans = _memory()
|
|
conv = "conv-quiet"
|
|
|
|
async def _forbidden(*_args, **_kwargs):
|
|
raise AssertionError("full history snapshot was built")
|
|
|
|
memory.simple_message = _forbidden # type: ignore[method-assign]
|
|
memory.init(conv, enable_vis_message=False)
|
|
payload = "x" * 50_000
|
|
for index in range(6):
|
|
await memory.append_message(
|
|
conv,
|
|
_message(
|
|
conv,
|
|
payload,
|
|
rounds=index,
|
|
action_report=json.dumps({"content": f"step-{index}-" + payload[:32]}),
|
|
),
|
|
)
|
|
queue = memory.queue(conv)
|
|
assert queue is not None and queue.empty()
|
|
assert len(store.rows) == 6
|
|
assert store.deleted == []
|
|
assert plans.removed == []
|
|
memory.dispose_runtime(conv)
|
|
assert memory.queue(conv) is None
|
|
assert store.deleted == []
|
|
assert plans.removed == []
|
|
assert len(store.rows) == 6
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_vis_off_reader_receives_only_the_latest_message():
|
|
async def _run() -> None:
|
|
memory, _store, _plans = _memory()
|
|
conv = "conv-reader"
|
|
memory.init(conv, enable_vis_message=False)
|
|
memory.attach_queue_reader(conv)
|
|
for index in range(4):
|
|
await memory.append_message(
|
|
conv,
|
|
_message(
|
|
conv,
|
|
f"body-{index}",
|
|
rounds=index,
|
|
action_report=json.dumps({"content": f"view-{index}"}),
|
|
),
|
|
)
|
|
queued = []
|
|
queue = memory.queue(conv)
|
|
assert queue is not None
|
|
while not queue.empty():
|
|
queued.append(queue.get_nowait())
|
|
assert [item[0]["markdown"] for item in queued] == [f"view-{i}" for i in range(4)]
|
|
assert all(len(item) == 1 for item in queued)
|
|
memory.detach_queue_reader(conv)
|
|
await memory.complete(conv)
|
|
assert queue.get_nowait() == "[DONE]"
|
|
memory.dispose_runtime(conv)
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_vis_on_still_publishes_the_legacy_view():
|
|
async def _run() -> None:
|
|
memory, _store, _plans = _memory()
|
|
conv = "conv-vis"
|
|
|
|
async def _link(_conv_id: str) -> str:
|
|
return "VIS-SNAPSHOT"
|
|
|
|
memory.app_link_chat_message = _link # type: ignore[method-assign]
|
|
memory.init(conv, enable_vis_message=True)
|
|
await memory.append_message(conv, _message(conv, "hello", rounds=0))
|
|
assert memory.queue(conv).get_nowait() == "VIS-SNAPSHOT"
|
|
memory.clear(conv)
|
|
assert memory._executor is not None
|
|
memory.shutdown_executor()
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_clear_does_not_delete_persisted_rows_or_owned_executor():
|
|
async def _run() -> None:
|
|
memory, store, plans = _memory()
|
|
conv = "conv-clear"
|
|
memory.init(conv, enable_vis_message=False)
|
|
await memory.append_message(conv, _message(conv, "kept", rounds=0))
|
|
executor = memory._executor
|
|
memory.clear(conv)
|
|
assert store.get_by_conv_id(conv)[0].content == "kept"
|
|
assert store.deleted == []
|
|
assert plans.removed == []
|
|
assert memory._executor is executor
|
|
memory.shutdown_executor()
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_next_turn_reloads_history_after_dispose():
|
|
async def _run() -> None:
|
|
memory, store, plans = _memory()
|
|
conv = "conv-reload"
|
|
report = json.dumps({"content": "persisted-answer", "is_exe_success": True})
|
|
memory.init(conv, enable_vis_message=False)
|
|
await memory.append_message(
|
|
conv,
|
|
_message(conv, "what is revenue?", rounds=0, sender="Human", receiver="ReAct"),
|
|
)
|
|
await memory.append_message(
|
|
conv,
|
|
_message(conv, "persisted-answer", rounds=1, sender="ReAct", action_report=report),
|
|
)
|
|
memory.dispose_runtime(conv)
|
|
assert memory._executor is None
|
|
assert store.deleted == []
|
|
assert plans.removed == []
|
|
|
|
reloaded, _store, plans2 = _memory(store)
|
|
reloaded.init(conv, enable_vis_message=False)
|
|
rows = await reloaded.get_messages(conv)
|
|
assert [row.content for row in rows] == ["what is revenue?", "persisted-answer"]
|
|
history = await reloaded.get_agent_history_memory(conv, "ReAct")
|
|
assert history and history[0].content == "persisted-answer"
|
|
assert plans2.removed == []
|
|
reloaded.dispose_runtime(conv)
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_metadb_rows_survive_dispose(tmp_path):
|
|
from sqlalchemy.pool import NullPool
|
|
|
|
from kamidso.engine.core.storage.metadata.db_manager import db
|
|
from kamidso.engine.serve.agent.agents.db_gpts_memory import (
|
|
MetaDbGptsMessageMemory,
|
|
MetaDbGptsPlansMemory,
|
|
)
|
|
|
|
db_file = tmp_path / "lifecycle.db"
|
|
db.init_db(
|
|
f"sqlite:///{db_file.as_posix()}",
|
|
engine_args={"connect_args": {"check_same_thread": False}, "poolclass": NullPool},
|
|
)
|
|
db.create_all()
|
|
|
|
async def _run() -> None:
|
|
conv = "conv-metadb-lifecycle"
|
|
memory = GptsMemory(
|
|
plans_memory=MetaDbGptsPlansMemory(),
|
|
message_memory=MetaDbGptsMessageMemory(),
|
|
)
|
|
memory.init(conv, enable_vis_message=False)
|
|
await memory.append_message(conv, _message(conv, "durable", rounds=1, sender="Human"))
|
|
memory.dispose_runtime(conv)
|
|
rows = MetaDbGptsMessageMemory().get_by_conv_id(conv)
|
|
assert rows and rows[0].content == "durable"
|
|
follow = GptsMemory(
|
|
plans_memory=MetaDbGptsPlansMemory(),
|
|
message_memory=MetaDbGptsMessageMemory(),
|
|
)
|
|
follow.init(conv, enable_vis_message=False)
|
|
loaded = await follow.get_messages(conv)
|
|
assert loaded[0].content == "durable"
|
|
follow.dispose_runtime(conv)
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_same_conversation_is_serialized_and_lock_survives_a_waiter():
|
|
async def _run() -> None:
|
|
_reset_runtime()
|
|
conv = "conv-same"
|
|
order: list[str] = []
|
|
locks: list[asyncio.Lock] = []
|
|
|
|
class _Scratch:
|
|
def __init__(self) -> None:
|
|
self.disposed = False
|
|
|
|
def dispose_runtime(self, conv_id: str | None = None) -> None:
|
|
del conv_id
|
|
self.disposed = True
|
|
|
|
async def _turn(name: str, hold: float) -> None:
|
|
lock = await acquire_conversation_run(conv)
|
|
locks.append(lock)
|
|
order.append(f"enter:{name}")
|
|
try:
|
|
if conv not in REACT_AGENT_MEMORY_CACHE:
|
|
REACT_AGENT_MEMORY_CACHE[conv] = _Scratch()
|
|
await asyncio.sleep(hold)
|
|
order.append(f"exit:{name}")
|
|
finally:
|
|
await end_react_turn(conv, lock)
|
|
|
|
first = asyncio.create_task(_turn("a", 0.08))
|
|
await asyncio.sleep(0.02)
|
|
second = asyncio.create_task(_turn("b", 0.01))
|
|
await asyncio.sleep(0.02)
|
|
assert conversation_lock_refcount(conv) == 2
|
|
assert REACT_CONVERSATION_LOCKS[conv].locked()
|
|
await first
|
|
await second
|
|
assert order == ["enter:a", "exit:a", "enter:b", "exit:b"]
|
|
assert locks[0] is locks[1]
|
|
assert conv not in REACT_CONVERSATION_LOCKS
|
|
assert conv not in REACT_AGENT_MEMORY_CACHE
|
|
assert conversation_lock_refcount(conv) == 0
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_cancelled_waiter_does_not_pin_the_conversation_lock():
|
|
async def _run() -> None:
|
|
_reset_runtime()
|
|
conv = "conv-cancel-wait"
|
|
holder = await acquire_conversation_run(conv)
|
|
|
|
async def _wait() -> None:
|
|
await acquire_conversation_run(conv)
|
|
|
|
waiter = asyncio.create_task(_wait())
|
|
await asyncio.sleep(0.05)
|
|
assert conversation_lock_refcount(conv) == 2
|
|
waiter.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await waiter
|
|
assert conversation_lock_refcount(conv) == 1
|
|
assert REACT_CONVERSATION_LOCKS[conv] is holder
|
|
await release_conversation_run(conv, holder)
|
|
assert conv not in REACT_CONVERSATION_LOCKS
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_twenty_different_conversations_run_together():
|
|
async def _run() -> None:
|
|
_reset_runtime()
|
|
active = 0
|
|
peak = 0
|
|
|
|
async def _one(index: int) -> None:
|
|
nonlocal active, peak
|
|
conv = f"user-{index}"
|
|
lock = await acquire_conversation_run(conv)
|
|
active += 1
|
|
peak = max(peak, active)
|
|
await asyncio.sleep(0.05)
|
|
active -= 1
|
|
await release_conversation_run(conv, lock)
|
|
|
|
await asyncio.gather(*(_one(index) for index in range(20)))
|
|
assert peak == 20
|
|
assert REACT_CONVERSATION_LOCKS == {}
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_repeated_turns_release_scratch_and_allocated_memory_stabilizes():
|
|
async def _cycle(store: _LatestStore, index: int) -> None:
|
|
memory, _store, _plans = _memory(store)
|
|
conv = "conv-repeat"
|
|
memory.init(conv, enable_vis_message=False)
|
|
payload = ("row-" + str(index) + "-") * 4_000
|
|
for step in range(6):
|
|
await memory.append_message(
|
|
conv,
|
|
_message(
|
|
conv,
|
|
payload,
|
|
rounds=step,
|
|
action_report=json.dumps({"content": payload[:64]}),
|
|
),
|
|
)
|
|
assert memory.queue(conv).empty()
|
|
executor = memory._executor
|
|
memory.dispose_runtime(conv)
|
|
assert executor is not None and getattr(executor, "_shutdown", False)
|
|
assert memory._executor is None
|
|
assert memory.queue(conv) is None
|
|
assert conv not in memory.messages_cache
|
|
|
|
async def _run() -> None:
|
|
store = _LatestStore()
|
|
tracemalloc.start()
|
|
alloc: list[int] = []
|
|
rss: list[int] = []
|
|
process = psutil.Process()
|
|
try:
|
|
for index in range(100):
|
|
await _cycle(store, index)
|
|
if index % 10 == 9:
|
|
gc.collect()
|
|
current, _peak = tracemalloc.get_traced_memory()
|
|
alloc.append(current)
|
|
rss.append(process.memory_info().rss)
|
|
finally:
|
|
tracemalloc.stop()
|
|
growth = alloc[-1] - alloc[2]
|
|
print(
|
|
"LIFECYCLE_MEASURE",
|
|
f"alloc_bytes={alloc}",
|
|
f"rss_bytes={rss}",
|
|
f"alloc_growth={growth}",
|
|
f"rss_growth={rss[-1] - rss[2]}",
|
|
)
|
|
assert store.deleted == []
|
|
assert len(store.rows) == 1
|
|
# Warmup is the first samples. Later allocated memory must not climb
|
|
# with the number of turns. RSS may stay above the process baseline.
|
|
assert growth < 8 * 1024 * 1024
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_adapter_release_covers_done_error_and_abandoned_runs():
|
|
done_id = "run-done"
|
|
error_id = "run-error"
|
|
quiet_id = "run-quiet"
|
|
active_id = "run-active"
|
|
try:
|
|
begin_adapter_run(done_id)
|
|
mapped = map_engine_event(done_id, {"type": "step.start", "id": "s", "title": "sql"})
|
|
assert mapped and mapped[0]["type"] == "run.started"
|
|
assert adapter_run_is_active(done_id)
|
|
release_adapter_run(done_id)
|
|
assert not adapter_run_is_active(done_id)
|
|
|
|
begin_adapter_run(error_id)
|
|
try:
|
|
map_engine_event(error_id, {"type": "step.start", "id": "s", "title": "code"})
|
|
raise RuntimeError("stream failed")
|
|
except RuntimeError:
|
|
release_adapter_run(error_id)
|
|
assert not adapter_run_is_active(error_id)
|
|
|
|
map_engine_event(quiet_id, {"type": "step.start", "id": "s", "title": "sql"})
|
|
begin_adapter_run(active_id)
|
|
map_engine_event(active_id, {"type": "step.start", "id": "s", "title": "sql"})
|
|
with adapter_events._LOCK:
|
|
adapter_events._TOUCHED[quiet_id] = 0.0
|
|
adapter_events._TOUCHED[active_id] = 0.0
|
|
removed = sweep_adapter_cache(now=10_000.0)
|
|
assert quiet_id in removed
|
|
assert active_id not in removed
|
|
assert adapter_run_is_active(active_id)
|
|
finally:
|
|
release_adapter_run(done_id)
|
|
release_adapter_run(error_id)
|
|
release_adapter_run(quiet_id)
|
|
release_adapter_run(active_id)
|
|
|
|
|
|
def test_release_does_not_drop_another_active_run():
|
|
first = "run-keep"
|
|
second = "run-drop"
|
|
try:
|
|
begin_adapter_run(first)
|
|
begin_adapter_run(second)
|
|
map_engine_event(first, {"type": "step.start", "id": "a", "title": "sql"})
|
|
map_engine_event(second, {"type": "step.start", "id": "b", "title": "sql"})
|
|
release_adapter_run(second)
|
|
assert adapter_run_is_active(first)
|
|
assert not adapter_run_is_active(second)
|
|
assert adapter_cache_size() >= 1
|
|
finally:
|
|
release_adapter_run(first)
|
|
release_adapter_run(second)
|
|
|
|
|
|
def test_engine_releases_adapter_on_success_error_and_disconnect():
|
|
async def _collect(engine: KamidsoEngine, run_id: str):
|
|
run = SimpleNamespace(id=run_id)
|
|
events = []
|
|
async for event in engine.execute(
|
|
user=None, # type: ignore[arg-type]
|
|
run=run, # type: ignore[arg-type]
|
|
request=None, # type: ignore[arg-type]
|
|
materialized=[],
|
|
):
|
|
events.append(event)
|
|
return events
|
|
|
|
async def _run() -> None:
|
|
engine = KamidsoEngine.__new__(KamidsoEngine)
|
|
engine._runtime = SimpleNamespace(ready=False)
|
|
failed = await _collect(engine, "run-unavailable")
|
|
assert failed[0]["error"]["code"] == "ENGINE_UNAVAILABLE"
|
|
assert not adapter_run_is_active("run-unavailable")
|
|
|
|
engine._runtime = SimpleNamespace(ready=True)
|
|
|
|
async def _ok(**_kwargs):
|
|
yield {"type": "run.delta"}
|
|
|
|
engine._execute_ready = _ok # type: ignore[method-assign]
|
|
events = await _collect(engine, "run-ok")
|
|
assert events == [{"type": "run.delta"}]
|
|
assert not adapter_run_is_active("run-ok")
|
|
|
|
async def _boom(**_kwargs):
|
|
yield {"type": "partial"}
|
|
raise RuntimeError("execution failed")
|
|
|
|
engine._execute_ready = _boom # type: ignore[method-assign]
|
|
with pytest.raises(RuntimeError):
|
|
await _collect(engine, "run-boom")
|
|
assert not adapter_run_is_active("run-boom")
|
|
|
|
async def _slow(**_kwargs):
|
|
yield {"type": "partial"}
|
|
await asyncio.sleep(30)
|
|
|
|
engine._execute_ready = _slow # type: ignore[method-assign]
|
|
generator = engine.execute(
|
|
user=None, # type: ignore[arg-type]
|
|
run=SimpleNamespace(id="run-disconnect"), # type: ignore[arg-type]
|
|
request=None, # type: ignore[arg-type]
|
|
materialized=[],
|
|
)
|
|
first = await generator.__anext__()
|
|
assert first["type"] == "partial"
|
|
await generator.aclose()
|
|
assert not adapter_run_is_active("run-disconnect")
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def _limit_bytes(text: str) -> float:
|
|
number, unit = str(text).split()
|
|
scales = {
|
|
"b": 1,
|
|
"byte": 1,
|
|
"bytes": 1,
|
|
"kib": 1024,
|
|
"mib": 1024**2,
|
|
"gib": 1024**3,
|
|
"tib": 1024**4,
|
|
"kb": 1000,
|
|
"mb": 1000**2,
|
|
"gb": 1000**3,
|
|
"tb": 1000**4,
|
|
}
|
|
return float(number) * scales[unit.lower()]
|
|
|
|
|
|
def test_duckdb_invalid_limit_falls_back_to_two_gigabytes(monkeypatch):
|
|
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "not-a-size")
|
|
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
|
|
assert duck_mem.configured_memory_limit() == "2GB"
|
|
assert duck_mem.configured_aggregate_limit() == "2GB"
|
|
|
|
|
|
def test_duckdb_tool_connections_share_one_aggregate_budget(monkeypatch):
|
|
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "2GB")
|
|
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
|
|
duck_mem.reset_duckdb_memory_state_for_tests()
|
|
duck_mem.cap_duckdb_default_connection()
|
|
first = duck_mem.open_tool_duckdb(":memory:")
|
|
second = None
|
|
try:
|
|
alone = first.execute("SELECT current_setting('memory_limit')").fetchone()[0]
|
|
assert _limit_bytes(alone) == pytest.approx(2_000_000_000, rel=0.15)
|
|
assert len(duck_mem._LIVE) == 1
|
|
second = duck_mem.open_tool_duckdb(":memory:")
|
|
limits = [
|
|
_limit_bytes(conn.execute("SELECT current_setting('memory_limit')").fetchone()[0])
|
|
for conn in (first, second)
|
|
]
|
|
assert all(700 * 1024**2 < limit < 1.2 * 1024**3 for limit in limits)
|
|
duck_mem.release_tool_duckdb(second)
|
|
second.close()
|
|
second = None
|
|
restored = first.execute("SELECT current_setting('memory_limit')").fetchone()[0]
|
|
assert _limit_bytes(restored) == pytest.approx(2_000_000_000, rel=0.15)
|
|
finally:
|
|
duck_mem.release_tool_duckdb(first)
|
|
first.close()
|
|
if second is not None:
|
|
duck_mem.release_tool_duckdb(second)
|
|
second.close()
|
|
duck_mem.reset_duckdb_memory_state_for_tests()
|
|
|
|
|
|
def test_excel_reader_registers_and_releases_duckdb(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "2GB")
|
|
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
|
|
duck_mem.reset_duckdb_memory_state_for_tests()
|
|
from kamidso.engine.app.scene.chat_data.chat_excel.excel_reader import ExcelReader
|
|
|
|
csv_path = tmp_path / "tiny.csv"
|
|
csv_path.write_text("city,amount\nShiraz,10\nTehran,20\n", encoding="utf-8")
|
|
monkeypatch.setattr(
|
|
"kamidso.engine.core.util.file_client.knowledge_upload_root",
|
|
lambda: str(tmp_path),
|
|
)
|
|
reader = ExcelReader("conv-excel", str(csv_path), read_type="df")
|
|
live_after_close = None
|
|
try:
|
|
limit = reader.db.execute("SELECT current_setting('memory_limit')").fetchone()[0]
|
|
assert _limit_bytes(limit) == pytest.approx(2_000_000_000, rel=0.15)
|
|
assert len(duck_mem._LIVE) == 1
|
|
rows = reader.db.execute("SELECT COUNT(*) FROM temp_table").fetchone()[0]
|
|
assert rows == 2
|
|
finally:
|
|
reader.close()
|
|
live_after_close = dict(duck_mem._LIVE)
|
|
duck_mem.reset_duckdb_memory_state_for_tests()
|
|
assert reader.db is None
|
|
assert live_after_close == {}
|
|
|
|
|
|
def test_duckdb_connector_binds_sqlalchemy_connections(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("KAMIDSO_DUCKDB_MEMORY_LIMIT", "2GB")
|
|
monkeypatch.delenv("KAMIDSO_DUCKDB_AGGREGATE_MEMORY_LIMIT", raising=False)
|
|
duck_mem.reset_duckdb_memory_state_for_tests()
|
|
from kamidso.engine.ext.datasource.rdbms.conn_duckdb import DuckDbConnector
|
|
|
|
connector = DuckDbConnector.from_file_path(str(tmp_path / "analytics.duckdb"))
|
|
try:
|
|
with connector._engine.connect() as connection:
|
|
limit = connection.exec_driver_sql("SELECT current_setting('memory_limit')").scalar()
|
|
assert _limit_bytes(str(limit)) == pytest.approx(2_000_000_000, rel=0.15)
|
|
finally:
|
|
connector._engine.dispose()
|
|
duck_mem.reset_duckdb_memory_state_for_tests()
|