From ab241ab48835a2c871df3cd16b299caec96a4e57 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Mon, 20 Apr 2026 15:49:35 +0200 Subject: [PATCH 1/2] bugfix: client-side lang cache refresh fixed (to prevent pulling stale objects) --- dbzero/dbzero/dbzero.py | 2 +- python_tests/test_cache.py | 129 ++++++++++++++++++++++++++++++- src/dbzero/workspace/Fixture.cpp | 11 ++- 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/dbzero/dbzero/dbzero.py b/dbzero/dbzero/dbzero.py index 6bf38564..c9e4f4dc 100644 --- a/dbzero/dbzero/dbzero.py +++ b/dbzero/dbzero/dbzero.py @@ -10,7 +10,7 @@ def load_dynamic(name, path): def __bootstrap__(): global __bootstrap__, __loader__, __file__ - paths = [os.path.join(os.path.split(__file__)[0]), "/src/dev/build/", "/usr/local/lib/python3/dist-packages/dbzero/"] + paths = [os.path.join(os.path.split(__file__)[0]), "/src/dev/build/release", "/usr/local/lib/python3/dist-packages/dbzero/"] __file__ = None for path in paths: if os.path.isdir(path): diff --git a/python_tests/test_cache.py b/python_tests/test_cache.py index 24d6ef5e..fed35067 100644 --- a/python_tests/test_cache.py +++ b/python_tests/test_cache.py @@ -2,9 +2,11 @@ # Copyright (c) 2025 DBZero Software sp. z o.o. import pytest +import multiprocessing import dbzero as db0 from random import randint -from .memo_test_types import MemoTestClass +from .conftest import DB0_DIR +from .memo_test_types import MemoTestClass, MemoTestSingleton def get_string(str_len): @@ -61,4 +63,127 @@ def test_lang_cache_can_reach_capacity(db0_fixture): # capacity not changed assert db0.get_lang_cache_stats()["capacity"] == initial_capacity # capacity might be exceeded due to indeterministic gc collection by Python - assert db0.get_lang_cache_stats()["size"] < initial_capacity * 2 \ No newline at end of file + assert db0.get_lang_cache_stats()["size"] < initial_capacity * 2 + + +# --- Writer process used by the stale-instance repro below. +# The writer cycles through: create obj, commit, (await reader), delete obj, +# create a replacement obj (likely reusing the same physical slot), commit. +def _writer_create_delete_create(prefix_name, req_queue, resp_queue): + db0.init(DB0_DIR) + db0.open(prefix_name, "rw") + # singleton holds a strong reference so we control exactly when the + # writer-side instance is dropped + root = MemoTestSingleton(None) + while True: + cmd = req_queue.get() + if cmd is None: + break + action = cmd[0] + if action == "create": + value = cmd[1] + obj = MemoTestClass(value) + root.value = obj + uuid = db0.uuid(obj) + del obj + db0.commit() + resp_queue.put(uuid) + elif action == "delete": + # drop the only strong reference and commit so the object + # and its slab slot are freed on disk before the next create + root.value = None + db0.commit() + resp_queue.put("ok") + db0.close() + + +def test_lang_cache_returns_stale_instance_after_slot_reuse(db0_fixture): + """ + Reproduces a lang-cache coherence bug on read-only clients. + + Scenario: a writer creates obj1, the reader fetches it into its + LangCache. The writer then deletes obj1 and creates obj2, which is + likely allocated at obj1's recycled physical address. After refresh, + the reader fetches obj2 by its new UUID; the client-side LangCache + is keyed by (fixture_id, address) - no instance_id - so it returns + the stale wrapper that was bound to obj1's (now recycled) slot. + + The test records UUIDs only - it intentionally does not keep Python + references to fetched wrappers. Holding a stale wrapper around + exposes a separate "stale instance access" class of bugs which is + out of scope here; dropping the wrapper after each fetch isolates + the LangCache coherence issue. The cache entry itself survives the + wrapper being dropped (it holds its own reference via + ObjectSharedExtPtr) until something explicitly clears it. + """ + prefix_name = db0.get_current_prefix().name + # make sure the prefix exists on disk before the reader opens it read-only + db0.commit() + db0.close() + + req_queue = multiprocessing.Queue() + resp_queue = multiprocessing.Queue() + writer = multiprocessing.Process( + target=_writer_create_delete_create, + args=(prefix_name, req_queue, resp_queue), + ) + writer.start() + + # Record (uuid, expected_value) pairs seen across all iterations so we + # can also validate post-loop that fetching any historical uuid either + # succeeds with the right value or fails cleanly - never silently + # returns a stale/wrong instance. + seen = [] + try: + db0.init(DB0_DIR) + db0.open(prefix_name, "r") + iterations = 50 + for i in range(iterations): + first_value = i + second_value = 100000 + i + + # writer: create obj1 (instance 0) + req_queue.put(("create", first_value)) + uuid1 = resp_queue.get(timeout=30) + + db0.refresh() + obj1 = db0.fetch(uuid1) + assert obj1.value == first_value, \ + f"iter {i}: unexpected obj1.value={obj1.value}" + assert db0.uuid(obj1) == uuid1, \ + f"iter {i}: uuid1 roundtrip failed" + # intentionally drop the wrapper immediately - we only retain + # the uuid, not the Python instance + del obj1 + + # writer: delete obj1 and commit + req_queue.put(("delete",)) + assert resp_queue.get(timeout=30) == "ok" + + # writer: create obj2 (instance 1) - likely reuses obj1's slot + req_queue.put(("create", second_value)) + uuid2 = resp_queue.get(timeout=30) + assert uuid1 != uuid2 + + db0.refresh() + obj2 = db0.fetch(uuid2) + # If the LangCache wrongly served the stale entry for obj1's + # address, db0.uuid(obj2) will not match the uuid we just + # resolved through, and obj2.value may report obj1's value. + assert db0.uuid(obj2) == uuid2, ( + f"iter {i}: uuid mismatch - lang cache served stale wrapper " + f"(expected {uuid2}, got {db0.uuid(obj2)})" + ) + assert obj2.value == second_value, ( + f"iter {i}: lang cache returned stale instance " + f"(expected {second_value}, got {obj2.value}); " + f"uuid1={uuid1} uuid2={uuid2}" + ) + seen.append((uuid2, second_value)) + del obj2 + finally: + req_queue.put(None) + writer.join(timeout=30) + if writer.is_alive(): + writer.terminate() + writer.join() \ No newline at end of file diff --git a/src/dbzero/workspace/Fixture.cpp b/src/dbzero/workspace/Fixture.cpp index a89edff6..0f41ff35 100644 --- a/src/dbzero/workspace/Fixture.cpp +++ b/src/dbzero/workspace/Fixture.cpp @@ -230,7 +230,16 @@ namespace db0 if (!Memspace::beginRefresh()) { return false; } - + + // Drop all language-side cache entries mapped to this fixture before + // detaching. The LangCache key is (fixture_id, address.offset) and does + // not include instance_id, so any slot reused by the writer since the + // last refresh would cause a subsequent fetch to return a stale wrapper + // bound to the previous logical object. Clearing here forces the next + // fetch to materialize a fresh wrapper. Done before detachAll so we + // don't waste work detaching entries that are about to be dropped. + m_lang_cache.clear(false); + if (m_gc0_ptr) { // detach all active ObjectBase instances so that they can be refreshed m_gc0_ptr->detachAll(); From 6466026ca6746e1f75bf983e10b9889b7a475123 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Mon, 20 Apr 2026 16:07:12 +0200 Subject: [PATCH 2/2] version update --- dbzero/setup.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dbzero/setup.py b/dbzero/setup.py index 7398bcb9..4dea166c 100644 --- a/dbzero/setup.py +++ b/dbzero/setup.py @@ -10,7 +10,7 @@ setup( name='dbzero', - version='0.2.1', + version='0.2.2', description='DBZero community edition', packages=['dbzero'], python_requires='>=3.9', diff --git a/pyproject.toml b/pyproject.toml index 5aeb6f0b..fb8535a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ['meson-python'] [project] name = 'dbzero' -version = '0.2.1' +version = '0.2.2' description = 'A state management system for Python 3.x that unifies your applications business logic, data persistence, and caching into a single, efficient layer.' readme = 'README.md' requires-python = '>=3.9'