sibyllabs's picture
Snapshot mirror of Sibyl-Labs/Sibyl-Memory@e2241dbcff6840674d616c3ab3c1389ddf0c2f2d
5e3d88d
Raw
History Blame Contribute Delete
18.5 kB
"""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
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
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)
# --------------------------------------------------------------------------- #
# 1. a* methods each work and match their sync equivalents (parity)
# --------------------------------------------------------------------------- #
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"})
# adelete (dispatches to abatch -> PutOp(value=None))
await store.adelete(("ns", "d"), "k1")
assert await store.aget(("ns", "d"), "k1") is None
# sync delete still present-parity: k2 removed via sync, observed async
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)
# each async hit is a SearchItem
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)
# content sanity
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()
# --------------------------------------------------------------------------- #
# 2. abatch() — mixed ops aligned by index; empty -> []
# --------------------------------------------------------------------------- #
def test_abatch_mixed_ops_aligned_by_index():
store = _mkstore()
try:
# seed
store.put(("memories", "u1"), "seed", {"text": "seeded dark note", "kind": "pref"})
ops = [
PutOp(("memories", "u1"), "new1", {"text": "fresh item", "kind": "ops"}), # 0 -> None
GetOp(("memories", "u1"), "seed"), # 1 -> Item
GetOp(("memories", "u1"), "absent"), # 2 -> None
SearchOp(("memories",), query="dark"), # 3 -> list[SearchItem]
ListNamespacesOp(), # 4 -> list[tuple]
PutOp(("memories", "u1"), "seed", None), # 5 -> None (delete)
]
async def main():
return await store.abatch(ops)
res = _run(main())
assert len(res) == len(ops)
assert res[0] is None # Put returns None
assert isinstance(res[1], Item) and res[1].key == "seed"
assert res[2] is None # missing Get -> 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 # delete Put -> None
# side effects landed: new1 created, seed deleted
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})
# interleave present/absent keys to verify positional alignment
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()
# --------------------------------------------------------------------------- #
# 3. concurrency — gather of many aput to DISTINCT keys; read all back
# --------------------------------------------------------------------------- #
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}"
# cross-check via list/search count
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):
# may or may not see it yet; must never raise / corrupt
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)) # interleaved with the write
await asyncio.gather(*tasks)
# final settle: everything must be present + correct
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
# value must be a clean, complete dict from exactly one writer
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()
# --------------------------------------------------------------------------- #
# 4. cross-thread / repeated-op watch (the SQLite thread-affinity hunt)
# --------------------------------------------------------------------------- #
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
# mix in searches + namespace listings which also hit the pool
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():
# burst 1
await asyncio.gather(*[store.aput(("ns", "s"), f"a{i}", {"i": i}) for i in range(50)])
# sequential
for i in range(50):
assert (await store.aget(("ns", "s"), f"a{i}")).value["i"] == i
# burst 2 (overwrites + new)
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()
# --------------------------------------------------------------------------- #
# 5. event loop is not blocked (offload sanity)
# --------------------------------------------------------------------------- #
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):
# runs alongside the store-op gather; counts loop turns it gets
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())
# If the loop were blocked by synchronous SQLite work, the ticker would
# get few/zero turns. Offloading lets it spin many times.
assert ticks > 1, f"loop appears blocked during store ops (ticks={ticks})"
finally:
store.close()
if __name__ == "__main__": # allow direct execution too
raise SystemExit(pytest.main([__file__, "-v"]))