| """Rigorous coverage of SibylStore's ASYNC API + CONCURRENCY. |
| |
| Dimension: async (abatch + a* methods) and concurrency. |
| |
| pytest-asyncio is NOT installed. Every test is a plain function that drives an |
| inner coroutine via ``asyncio.run(...)``. Each test gets an isolated on-disk DB. |
| |
| What the contract says (intentional, NOT bugs): |
| * a* methods (aget/aput/adelete/asearch/alist_namespaces) dispatch to abatch(). |
| * abatch() offloads the synchronous SQLite batch() to a thread-pool executor. |
| * search() is lexical (FTS5); index/ttl ignored; namespace rules as elsewhere. |
| |
| Watch: SQLite cross-thread connection errors. abatch offloads to a thread pool, |
| so repeated + gathered async ops are exercised to surface any |
| "SQLite objects created in a thread can only be used in another thread". |
| """ |
|
|
| from __future__ import annotations |
|
|
| import asyncio |
| import os |
| import tempfile |
|
|
| import pytest |
|
|
| from langgraph.store.base import ( |
| GetOp, |
| Item, |
| ListNamespacesOp, |
| PutOp, |
| SearchItem, |
| SearchOp, |
| ) |
| from sibyl_memory_langgraph import SibylStore |
|
|
|
|
| |
| |
| |
| def _mkstore() -> SibylStore: |
| """Fresh isolated on-disk store (own temp dir => own SQLite file).""" |
| d = tempfile.mkdtemp() |
| return SibylStore(path=os.path.join(d, "t.db"), tier="free") |
|
|
|
|
| def _run(coro): |
| """Drive a coroutine on a fresh event loop (no pytest-asyncio).""" |
| return asyncio.run(coro) |
|
|
|
|
| def _kv(item): |
| """Comparable projection of an Item/SearchItem ignoring timestamps.""" |
| if item is None: |
| return None |
| return (tuple(item.namespace), item.key, item.value) |
|
|
|
|
| |
| |
| |
| def test_aput_aget_basic(): |
| store = _mkstore() |
| try: |
| async def main(): |
| await store.aput(("memories", "u1"), "fact1", |
| {"text": "operator prefers dark mode", "kind": "pref"}) |
| it = await store.aget(("memories", "u1"), "fact1") |
| assert it is not None |
| assert isinstance(it, Item) |
| assert it.value == {"text": "operator prefers dark mode", "kind": "pref"} |
| assert tuple(it.namespace) == ("memories", "u1") |
| assert it.key == "fact1" |
| assert it.created_at is not None and it.updated_at is not None |
| _run(main()) |
| finally: |
| store.close() |
|
|
|
|
| def test_aget_missing_returns_none(): |
| store = _mkstore() |
| try: |
| async def main(): |
| assert await store.aget(("memories", "u1"), "nope") is None |
| _run(main()) |
| finally: |
| store.close() |
|
|
|
|
| def test_aput_get_parity_sync_writes_async_reads(): |
| """An item written synchronously is observably identical when read async.""" |
| store = _mkstore() |
| try: |
| store.put(("ns", "a"), "k", {"v": 1, "text": "hello world"}) |
|
|
| async def main(): |
| return await store.aget(("ns", "a"), "k") |
| async_item = _run(main()) |
| sync_item = store.get(("ns", "a"), "k") |
| assert _kv(async_item) == _kv(sync_item) |
| assert _kv(async_item) == (("ns", "a"), "k", {"v": 1, "text": "hello world"}) |
| finally: |
| store.close() |
|
|
|
|
| def test_aput_get_parity_async_writes_sync_reads(): |
| """An item written asynchronously is observably identical when read sync.""" |
| store = _mkstore() |
| try: |
| async def main(): |
| await store.aput(("ns", "b"), "k", {"v": 2, "text": "second item"}) |
| _run(main()) |
| sync_item = store.get(("ns", "b"), "k") |
| assert _kv(sync_item) == (("ns", "b"), "k", {"v": 2, "text": "second item"}) |
| finally: |
| store.close() |
|
|
|
|
| def test_aput_overwrite(): |
| store = _mkstore() |
| try: |
| async def main(): |
| await store.aput(("ns", "o"), "k", {"text": "dark mode"}) |
| await store.aput(("ns", "o"), "k", {"text": "light mode"}) |
| it = await store.aget(("ns", "o"), "k") |
| assert it.value["text"] == "light mode" |
| _run(main()) |
| finally: |
| store.close() |
|
|
|
|
| def test_adelete_and_parity_with_sync_delete(): |
| store = _mkstore() |
| try: |
| async def main(): |
| await store.aput(("ns", "d"), "k1", {"text": "alpha"}) |
| await store.aput(("ns", "d"), "k2", {"text": "beta"}) |
| |
| await store.adelete(("ns", "d"), "k1") |
| assert await store.aget(("ns", "d"), "k1") is None |
| |
| store.delete(("ns", "d"), "k2") |
| assert await store.aget(("ns", "d"), "k2") is None |
| _run(main()) |
| finally: |
| store.close() |
|
|
|
|
| def test_aput_none_value_deletes(): |
| """aput(value=None) deletes (mirrors sync semantics).""" |
| store = _mkstore() |
| try: |
| async def main(): |
| await store.aput(("ns", "n"), "k", {"text": "to be removed"}) |
| assert await store.aget(("ns", "n"), "k") is not None |
| await store.aput(("ns", "n"), "k", None) |
| assert await store.aget(("ns", "n"), "k") is None |
| _run(main()) |
| finally: |
| store.close() |
|
|
|
|
| def test_asearch_parity_with_sync(): |
| store = _mkstore() |
| try: |
| store.put(("memories", "u1"), "f1", {"text": "operator prefers dark mode", "kind": "pref"}) |
| store.put(("memories", "u1"), "f2", {"text": "billing handled by stripe", "kind": "ops"}) |
| store.put(("memories", "u2"), "f1", {"text": "another dark theme note", "kind": "pref"}) |
|
|
| async def main(): |
| a_exact = await store.asearch(("memories", "u1"), query="stripe") |
| a_subtree = await store.asearch(("memories",), query="dark") |
| a_filter = await store.asearch(("memories", "u1"), filter={"kind": "ops"}) |
| a_browse = await store.asearch(("memories", "u1")) |
| return a_exact, a_subtree, a_filter, a_browse |
| a_exact, a_subtree, a_filter, a_browse = _run(main()) |
|
|
| s_exact = store.search(("memories", "u1"), query="stripe") |
| s_subtree = store.search(("memories",), query="dark") |
| s_filter = store.search(("memories", "u1"), filter={"kind": "ops"}) |
| s_browse = store.search(("memories", "u1")) |
|
|
| def proj(hits): |
| return sorted((tuple(h.namespace), h.key, tuple(sorted(h.value.items()))) |
| for h in hits) |
|
|
| |
| for h in a_exact + a_subtree + a_filter + a_browse: |
| assert isinstance(h, SearchItem) |
|
|
| assert proj(a_exact) == proj(s_exact) |
| assert proj(a_subtree) == proj(s_subtree) |
| assert proj(a_filter) == proj(s_filter) |
| assert proj(a_browse) == proj(s_browse) |
|
|
| |
| assert any(h.key == "f2" for h in a_exact) |
| assert all(tuple(h.namespace) == ("memories", "u1") for h in a_exact) |
| assert {tuple(h.namespace) for h in a_subtree} >= {("memories", "u1"), ("memories", "u2")} |
| assert all(h.value.get("kind") == "ops" for h in a_filter) and len(a_filter) >= 1 |
| assert len(a_browse) == 2 |
| finally: |
| store.close() |
|
|
|
|
| def test_alist_namespaces_parity_with_sync(): |
| store = _mkstore() |
| try: |
| store.put(("memories", "u1"), "f1", {"x": 1}) |
| store.put(("memories", "u2"), "f1", {"x": 2}) |
| store.put(("notes", "u1"), "f1", {"x": 3}) |
|
|
| async def main(): |
| full = await store.alist_namespaces() |
| depth1 = await store.alist_namespaces(max_depth=1) |
| return full, depth1 |
| a_full, a_depth1 = _run(main()) |
|
|
| assert sorted(map(tuple, a_full)) == sorted(map(tuple, store.list_namespaces())) |
| assert sorted(map(tuple, a_depth1)) == sorted(map(tuple, store.list_namespaces(max_depth=1))) |
| assert ("memories", "u1") in [tuple(n) for n in a_full] |
| assert ("memories", "u2") in [tuple(n) for n in a_full] |
| assert ("memories",) in [tuple(n) for n in a_depth1] |
| finally: |
| store.close() |
|
|
|
|
| |
| |
| |
| def test_abatch_mixed_ops_aligned_by_index(): |
| store = _mkstore() |
| try: |
| |
| store.put(("memories", "u1"), "seed", {"text": "seeded dark note", "kind": "pref"}) |
|
|
| ops = [ |
| PutOp(("memories", "u1"), "new1", {"text": "fresh item", "kind": "ops"}), |
| GetOp(("memories", "u1"), "seed"), |
| GetOp(("memories", "u1"), "absent"), |
| SearchOp(("memories",), query="dark"), |
| ListNamespacesOp(), |
| PutOp(("memories", "u1"), "seed", None), |
| ] |
|
|
| async def main(): |
| return await store.abatch(ops) |
| res = _run(main()) |
|
|
| assert len(res) == len(ops) |
| assert res[0] is None |
| assert isinstance(res[1], Item) and res[1].key == "seed" |
| assert res[2] is None |
| assert isinstance(res[3], list) and all(isinstance(h, SearchItem) for h in res[3]) |
| assert isinstance(res[4], list) and ("memories", "u1") in [tuple(n) for n in res[4]] |
| assert res[5] is None |
|
|
| |
| assert store.get(("memories", "u1"), "new1") is not None |
| assert store.get(("memories", "u1"), "seed") is None |
| finally: |
| store.close() |
|
|
|
|
| def test_abatch_empty_returns_empty_list(): |
| store = _mkstore() |
| try: |
| async def main(): |
| return await store.abatch([]) |
| res = _run(main()) |
| assert res == [] |
| assert isinstance(res, list) |
| finally: |
| store.close() |
|
|
|
|
| def test_abatch_get_order_preserved_for_many_gets(): |
| """Index alignment under a larger homogeneous batch.""" |
| store = _mkstore() |
| try: |
| for i in range(20): |
| store.put(("ns", "ord"), f"k{i}", {"i": i}) |
| |
| keys = [] |
| for i in range(20): |
| keys.append(f"k{i}") |
| keys.append(f"missing{i}") |
| ops = [GetOp(("ns", "ord"), k) for k in keys] |
|
|
| async def main(): |
| return await store.abatch(ops) |
| res = _run(main()) |
|
|
| assert len(res) == len(ops) |
| for idx, k in enumerate(keys): |
| if k.startswith("missing"): |
| assert res[idx] is None, f"index {idx} ({k}) should be None" |
| else: |
| assert res[idx] is not None and res[idx].key == k |
| assert res[idx].value["i"] == int(k[1:]) |
| finally: |
| store.close() |
|
|
|
|
| |
| |
| |
| def test_concurrent_aput_distinct_keys_none_lost(): |
| store = _mkstore() |
| try: |
| N = 150 |
|
|
| async def main(): |
| await asyncio.gather(*[ |
| store.aput(("ns", "distinct"), f"k{i}", {"i": i, "text": f"item {i}"}) |
| for i in range(N) |
| ]) |
| got = await asyncio.gather(*[ |
| store.aget(("ns", "distinct"), f"k{i}") for i in range(N) |
| ]) |
| return got |
| got = _run(main()) |
|
|
| assert len(got) == N |
| for i, it in enumerate(got): |
| assert it is not None, f"key k{i} was lost" |
| assert it.value == {"i": i, "text": f"item {i}"}, f"key k{i} corrupted: {it.value}" |
|
|
| |
| browse = store.search(("ns", "distinct"), limit=1000) |
| assert len({h.key for h in browse}) == N |
| finally: |
| store.close() |
|
|
|
|
| def test_concurrent_interleaved_aput_aget_same_namespace(): |
| store = _mkstore() |
| try: |
| N = 80 |
|
|
| async def writer(i): |
| await store.aput(("ns", "shared"), f"k{i}", {"i": i}) |
|
|
| async def reader(i): |
| |
| it = await store.aget(("ns", "shared"), f"k{i}") |
| if it is not None: |
| assert it.value["i"] == i |
|
|
| async def main(): |
| tasks = [] |
| for i in range(N): |
| tasks.append(writer(i)) |
| tasks.append(reader(i)) |
| await asyncio.gather(*tasks) |
| |
| final = await asyncio.gather(*[store.aget(("ns", "shared"), f"k{i}") for i in range(N)]) |
| return final |
| final = _run(main()) |
| assert all(it is not None and it.value["i"] == i for i, it in enumerate(final)) |
| finally: |
| store.close() |
|
|
|
|
| def test_concurrent_overwrites_same_key_no_corruption(): |
| """Many concurrent writers to ONE key: final value is one valid write, never corrupt.""" |
| store = _mkstore() |
| try: |
| N = 60 |
|
|
| async def main(): |
| await asyncio.gather(*[ |
| store.aput(("ns", "hot"), "k", {"writer": i, "payload": f"v{i}"}) |
| for i in range(N) |
| ]) |
| return await store.aget(("ns", "hot"), "k") |
| it = _run(main()) |
| assert it is not None |
| |
| assert set(it.value.keys()) == {"writer", "payload"} |
| assert it.value["payload"] == f"v{it.value['writer']}" |
| assert 0 <= it.value["writer"] < N |
| finally: |
| store.close() |
|
|
|
|
| |
| |
| |
| def test_many_sequential_awaits_no_cross_thread_error(): |
| """50+ sequential awaits, each offloaded to the thread pool. Surfaces any |
| 'SQLite objects created in a thread can only be used in another thread'.""" |
| store = _mkstore() |
| try: |
| async def main(): |
| for i in range(80): |
| await store.aput(("ns", "seq"), f"k{i}", {"i": i}) |
| it = await store.aget(("ns", "seq"), f"k{i}") |
| assert it is not None and it.value["i"] == i |
| |
| for _ in range(20): |
| await store.asearch(("ns",), query="k") |
| await store.alist_namespaces() |
| _run(main()) |
| finally: |
| store.close() |
|
|
|
|
| def test_gathered_then_sequential_then_gathered_stress(): |
| """Alternate burst-concurrency and sequential phases to thrash the pool's |
| thread-local connections (each pool thread opens its own SQLite conn).""" |
| store = _mkstore() |
| try: |
| async def main(): |
| |
| await asyncio.gather(*[store.aput(("ns", "s"), f"a{i}", {"i": i}) for i in range(50)]) |
| |
| for i in range(50): |
| assert (await store.aget(("ns", "s"), f"a{i}")).value["i"] == i |
| |
| await asyncio.gather(*[store.aput(("ns", "s"), f"a{i}", {"i": i * 10}) for i in range(50)]) |
| got = await asyncio.gather(*[store.aget(("ns", "s"), f"a{i}") for i in range(50)]) |
| return got |
| got = _run(main()) |
| assert all(it is not None and it.value["i"] == i * 10 for i, it in enumerate(got)) |
| finally: |
| store.close() |
|
|
|
|
| |
| |
| |
| def test_gather_of_many_ops_completes_within_timeout(): |
| """Sanity: a gather of N ops completes (loop not deadlocked/blocked).""" |
| store = _mkstore() |
| try: |
| async def main(): |
| await asyncio.wait_for( |
| asyncio.gather(*[ |
| store.aput(("ns", "t"), f"k{i}", {"i": i}) for i in range(120) |
| ]), |
| timeout=30, |
| ) |
| results = await asyncio.wait_for( |
| asyncio.gather(*[store.aget(("ns", "t"), f"k{i}") for i in range(120)]), |
| timeout=30, |
| ) |
| return results |
| results = _run(main()) |
| assert sum(1 for r in results if r is not None) == 120 |
| finally: |
| store.close() |
|
|
|
|
| def test_event_loop_progresses_during_store_ops(): |
| """A concurrent ticker coroutine must make progress while store ops run, |
| proving abatch offloads instead of blocking the loop thread.""" |
| store = _mkstore() |
| try: |
| async def ticker(state): |
| |
| while not state["done"]: |
| state["ticks"] += 1 |
| await asyncio.sleep(0) |
| return state["ticks"] |
|
|
| async def workload(state): |
| await asyncio.gather(*[ |
| store.aput(("ns", "lp"), f"k{i}", {"i": i, "blob": "x" * 64}) |
| for i in range(200) |
| ]) |
| state["done"] = True |
|
|
| async def main(): |
| state = {"ticks": 0, "done": False} |
| t = asyncio.create_task(ticker(state)) |
| await workload(state) |
| await t |
| return state["ticks"] |
| ticks = _run(main()) |
| |
| |
| assert ticks > 1, f"loop appears blocked during store ops (ticks={ticks})" |
| finally: |
| store.close() |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(pytest.main([__file__, "-v"])) |
|
|