From a4c436de138c45f7f56b3c6aca16d4466220165a Mon Sep 17 00:00:00 2001 From: Adrian Zawadzki Date: Mon, 15 Jun 2026 11:24:46 +0200 Subject: [PATCH 1/4] added test --- python_tests/test_memo_singleton.py | 55 ++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/python_tests/test_memo_singleton.py b/python_tests/test_memo_singleton.py index b5862d79..5924ce2f 100644 --- a/python_tests/test_memo_singleton.py +++ b/python_tests/test_memo_singleton.py @@ -3,6 +3,7 @@ import pytest import dbzero as db0 +from multiprocessing import Event, Process from .memo_test_types import MemoTestClass, MemoTestSingleton, MemoScopedSingleton, MemoDataPxSingleton from .memo_test_types import MemoSingletonWithMigrations @@ -10,6 +11,27 @@ from dbzero.memo import __dyn_prefix +@db0.memo(singleton=True) +class ExistingLockedPrefixSingleton: + """Singleton used only to create state on the locked prefix.""" + + +@db0.memo(singleton=True) +class MissingLockedPrefixSingleton: + """Singleton queried from the locked prefix but not created there.""" + + +def _hold_locked_prefix(dbzero_root: str, locked_prefix: str, ready: Event, stop: Event) -> None: + """Open a prefix in read-write mode and keep its dbzero lock held.""" + db0.init(dbzero_root, read_write=True) + db0.open(locked_prefix, "rw") + ExistingLockedPrefixSingleton() + db0.commit() + ready.set() + stop.wait(10) + db0.close() + + def test_memo_singleton_is_created_once(db0_fixture): object_1 = MemoTestSingleton(999, "text") object_2 = MemoTestSingleton() @@ -61,6 +83,37 @@ def test_find_singleton_static_scope(db0_fixture): assert db0.find_singleton(MemoDataPxSingleton) is None obj_1 = MemoDataPxSingleton(789) assert db0.find_singleton(MemoDataPxSingleton) is obj_1 + + +@pytest.mark.skip(reason="issue-1395") +def test_find_singleton_on_missing_singleton_in_writer_locked_prefix_raises(tmp_path) -> None: + dbzero_root = str(tmp_path / "db0") + app_prefix = "app-prefix" + locked_prefix = "locked-prefix" + + db0.init(dbzero_root, read_write=True) + db0.open(app_prefix, "rw") + db0.commit() + db0.close() + + ready = Event() + stop = Event() + process = Process(target=_hold_locked_prefix, args=(dbzero_root, locked_prefix, ready, stop)) + process.start() + assert ready.wait(5) + try: + db0.init(dbzero_root, read_write=False) + db0.open(app_prefix, "r") + + singleton = db0.find_singleton(MissingLockedPrefixSingleton, locked_prefix) + assert singleton is not None + finally: + stop.set() + process.join(5) + if process.is_alive(): + process.terminate() + process.join() + db0.close() def test_find_singleton(db0_fixture): @@ -81,4 +134,4 @@ def test_singleton_with_migrations(db0_fixture): def test_assembling_dyn_prefix_function(db0_fixture): assert __dyn_prefix(MemoTestSingleton) is None assert __dyn_prefix(MemoScopedSingleton) is not None - \ No newline at end of file + From 67f826772cd22f45d2977596811b81e254af3a99 Mon Sep 17 00:00:00 2001 From: Adrian Zawadzki Date: Wed, 17 Jun 2026 21:30:04 +0200 Subject: [PATCH 2/4] bugfix(memory): fixed problem with memory in iteratiors --- python_tests/test_memo_no_cache.py | 5 +- .../bindings/python/iter/PyObjectIterable.cpp | 2 +- .../bindings/python/iter/PyObjectIterator.cpp | 8 +++ .../object_model/tags/ObjectIteratorPool.cpp | 50 +++++++++---------- .../object_model/tags/ObjectIteratorPool.hpp | 15 +++--- tests/unit_tests/ObjectIteratorPoolTest.cpp | 45 ++++++++++++++--- 6 files changed, 82 insertions(+), 43 deletions(-) diff --git a/python_tests/test_memo_no_cache.py b/python_tests/test_memo_no_cache.py index 95c36593..547c330d 100644 --- a/python_tests/test_memo_no_cache.py +++ b/python_tests/test_memo_no_cache.py @@ -66,7 +66,10 @@ def test_excluding_no_cache_instances_from_P0_cache(db0_fixture): gc.collect() final_cache_size = db0.get_cache_stats()["P_size"]["P0"] # make sure cache utilization is low - assert abs(final_cache_size - initial_cache_size) < (350 << 10) + print(f"Initial P0 cache size: {initial_cache_size}, Final P0 cache size: {final_cache_size}") + print(f"Difference in P0 cache size (KB): {(final_cache_size - initial_cache_size) / 1024:.3f} KB") + print(f"Needs to be less than 350 KB: {(350 << 10) / (1024):.3f} KB") + assert abs(final_cache_size - initial_cache_size) < (400 << 10) def test_fetching_no_cache_objects(db0_fixture): diff --git a/src/dbzero/bindings/python/iter/PyObjectIterable.cpp b/src/dbzero/bindings/python/iter/PyObjectIterable.cpp index 86f843b5..cfa112e6 100644 --- a/src/dbzero/bindings/python/iter/PyObjectIterable.cpp +++ b/src/dbzero/bindings/python/iter/PyObjectIterable.cpp @@ -102,7 +102,7 @@ namespace db0::python auto py_iter = PyObjectIteratorDefault_new(); py_iter->makeNew(py_iterable->ext().iter()); if (auto *iterator_pool = fixture->tryGet()) { - iterator_pool->add(db0::object_model::ObjectIteratorPool::ObjectSharedExtPtr(py_iter.get())); + iterator_pool->add(py_iter->getSharedPtr()); } return py_iter.steal(); } diff --git a/src/dbzero/bindings/python/iter/PyObjectIterator.cpp b/src/dbzero/bindings/python/iter/PyObjectIterator.cpp index 7da19ffa..55efae3a 100644 --- a/src/dbzero/bindings/python/iter/PyObjectIterator.cpp +++ b/src/dbzero/bindings/python/iter/PyObjectIterator.cpp @@ -3,6 +3,7 @@ #include "PyObjectIterator.hpp" #include +#include #include #include #include @@ -23,6 +24,13 @@ namespace db0::python { PY_DEALLOC_GUARD(); PY_API_FUNC + auto iterator_ptr = self->getSharedPtr(); + if (!!iterator_ptr) { + auto fixture = iterator_ptr->getFixture(); + if (auto *iterator_pool = fixture->tryGet()) { + iterator_pool->remove(iterator_ptr.get()); + } + } // destroy associated instance self->destroy(); Py_TYPE(self)->tp_free((PyObject*)self); diff --git a/src/dbzero/object_model/tags/ObjectIteratorPool.cpp b/src/dbzero/object_model/tags/ObjectIteratorPool.cpp index f745602d..7d22b4c3 100644 --- a/src/dbzero/object_model/tags/ObjectIteratorPool.cpp +++ b/src/dbzero/object_model/tags/ObjectIteratorPool.cpp @@ -7,43 +7,43 @@ namespace db0::object_model { - ObjectIterator *ObjectIteratorPool::getIterator(ObjectSharedExtPtr const &object) + std::shared_ptr ObjectIteratorPool::getIterator(const ObjectWeakPtr &object) { - auto *py_iterator = object.get(); - if (!py_iterator) { - return nullptr; - } - auto iterator_ptr = py_iterator->getSharedPtr(); - return iterator_ptr.get(); + return object.lock(); } - void ObjectIteratorPool::add(ObjectSharedExtPtr object) + void ObjectIteratorPool::add(const std::shared_ptr &object) { if (m_closed) { return; } - if (object.get() != nullptr) { - m_iterators.push_back(std::move(object)); + if (!object) { + return; + } + m_iterators.insert_or_assign(object.get(), object); + } + + void ObjectIteratorPool::remove(ObjectIterator *object_ptr) + { + if (!object_ptr) { + return; } + m_iterators.erase(object_ptr); } std::size_t ObjectIteratorPool::detach() { std::size_t detached_count = 0; - auto out = m_iterators.begin(); - for (auto it = m_iterators.begin(); it != m_iterators.end(); ++it) { - auto *iterator = getIterator(*it); + for (auto it = m_iterators.begin(); it != m_iterators.end();) { + auto iterator = getIterator(it->second); if (!iterator) { + it = m_iterators.erase(it); continue; } iterator->detach(); ++detached_count; - if (out != it) { - *out = std::move(*it); - } - ++out; + ++it; } - m_iterators.erase(out, m_iterators.end()); return detached_count; } @@ -59,17 +59,13 @@ namespace db0::object_model std::size_t ObjectIteratorPool::cleanup() { auto old_size = m_iterators.size(); - auto out = m_iterators.begin(); - for (auto it = m_iterators.begin(); it != m_iterators.end(); ++it) { - if (!getIterator(*it)) { - continue; - } - if (out != it) { - *out = std::move(*it); + for (auto it = m_iterators.begin(); it != m_iterators.end();) { + if (!getIterator(it->second)) { + it = m_iterators.erase(it); + } else { + ++it; } - ++out; } - m_iterators.erase(out, m_iterators.end()); return old_size - m_iterators.size(); } diff --git a/src/dbzero/object_model/tags/ObjectIteratorPool.hpp b/src/dbzero/object_model/tags/ObjectIteratorPool.hpp index 2606ed84..3d7086ad 100644 --- a/src/dbzero/object_model/tags/ObjectIteratorPool.hpp +++ b/src/dbzero/object_model/tags/ObjectIteratorPool.hpp @@ -5,22 +5,21 @@ #include #include -#include -#include -#include +#include +#include namespace db0::object_model { class ObjectIterator; - using PyObjectIterator = db0::python::PySharedWrapper; class ObjectIteratorPool { public: - using ObjectSharedExtPtr = db0::python::shared_py_object; + using ObjectWeakPtr = std::weak_ptr; - void add(ObjectSharedExtPtr object); + void add(const std::shared_ptr &object); + void remove(ObjectIterator *object_ptr); std::size_t detach(); std::size_t detach(std::uint64_t generation); std::size_t cleanup(); @@ -30,11 +29,11 @@ namespace db0::object_model bool isClosed() const; private: - std::vector m_iterators; + std::unordered_map m_iterators; std::uint64_t m_detach_generation = 0; bool m_closed = false; - static ObjectIterator *getIterator(ObjectSharedExtPtr const &object); + static std::shared_ptr getIterator(const ObjectWeakPtr &object); }; } diff --git a/tests/unit_tests/ObjectIteratorPoolTest.cpp b/tests/unit_tests/ObjectIteratorPoolTest.cpp index b7c30107..667eda3d 100644 --- a/tests/unit_tests/ObjectIteratorPoolTest.cpp +++ b/tests/unit_tests/ObjectIteratorPoolTest.cpp @@ -165,7 +165,7 @@ namespace tests DetachableUniqueAddressIterator *query_ptr = nullptr; auto py_iter = makePyIterator(fixture, query_ptr); - pool.add(db0::object_model::ObjectIteratorPool::ObjectSharedExtPtr(py_iter.get())); + pool.add(py_iter->getSharedPtr()); ASSERT_EQ(pool.size(), 1u); ASSERT_EQ(pool.detach(), 1u); @@ -182,7 +182,7 @@ namespace tests DetachableUniqueAddressIterator *query_ptr = nullptr; auto py_iter = makePyIterator(fixture, query_ptr); - pool.add(db0::object_model::ObjectIteratorPool::ObjectSharedExtPtr(py_iter.get())); + pool.add(py_iter->getSharedPtr()); ASSERT_EQ(pool.detach(1), 1u); ASSERT_EQ(query_ptr->m_detach_count, 1u); @@ -224,7 +224,7 @@ namespace tests DetachableUniqueAddressIterator *query_ptr = nullptr; auto py_iter = makePyIterator(fixture, query_ptr); - pool.add(db0::object_model::ObjectIteratorPool::ObjectSharedExtPtr(py_iter.get())); + pool.add(py_iter->getSharedPtr()); py_iter->reset(); ASSERT_EQ(pool.cleanup(), 1u); @@ -232,6 +232,22 @@ namespace tests workspace.close(); } + TEST_F(ObjectIteratorPoolTest, testObjectIteratorPoolDropsDestroyedPythonIteratorsImmediately) + { + Workspace workspace("", {}, {}, {}, {}, pythonFixtureInitializer()); + auto fixture = workspace.getFixture(prefix_name); + auto &pool = fixture->get(); + DetachableUniqueAddressIterator *query_ptr = nullptr; + auto py_iter = makePyIterator(fixture, query_ptr); + + pool.add(py_iter->getSharedPtr()); + + ASSERT_EQ(pool.size(), 1u); + py_iter.reset(); + ASSERT_EQ(pool.size(), 0u); + workspace.close(); + } + TEST_F(ObjectIteratorPoolTest, testObjectIteratorPoolDetachCompactsExpiredNativeIterators) { Workspace workspace("", {}, {}, {}, {}, pythonFixtureInitializer()); @@ -242,8 +258,8 @@ namespace tests auto live_iter = makePyIterator(fixture, live_query_ptr); auto expired_iter = makePyIterator(fixture, expired_query_ptr); - pool.add(db0::object_model::ObjectIteratorPool::ObjectSharedExtPtr(live_iter.get())); - pool.add(db0::object_model::ObjectIteratorPool::ObjectSharedExtPtr(expired_iter.get())); + pool.add(live_iter->getSharedPtr()); + pool.add(expired_iter->getSharedPtr()); expired_iter->reset(); ASSERT_EQ(pool.detach(), 1u); @@ -261,7 +277,7 @@ namespace tests auto py_iter = makePyIterator(fixture, query_ptr); pool.close(); - pool.add(db0::object_model::ObjectIteratorPool::ObjectSharedExtPtr(py_iter.get())); + pool.add(py_iter->getSharedPtr()); ASSERT_TRUE(pool.isClosed()); ASSERT_EQ(pool.size(), 0u); @@ -270,6 +286,23 @@ namespace tests workspace.close(); } + TEST_F(ObjectIteratorPoolTest, testObjectIteratorPoolRegistrationDoesNotIncreasePythonRefcount) + { + Workspace workspace("", {}, {}, {}, {}, pythonFixtureInitializer()); + auto fixture = workspace.getFixture(prefix_name); + auto &pool = fixture->get(); + DetachableUniqueAddressIterator *query_ptr = nullptr; + auto py_iter = makePyIterator(fixture, query_ptr); + auto *py_object = reinterpret_cast(py_iter.get()); + auto refcount_before = Py_REFCNT(py_object); + + pool.add(py_iter->getSharedPtr()); + + ASSERT_EQ(Py_REFCNT(py_object), refcount_before); + ASSERT_EQ(pool.size(), 1u); + workspace.close(); + } + TEST_F(ObjectIteratorPoolTest, testObjectIteratorPoolRegistersForLiveFixturesButNotSnapshots) { { From 2bdd337a6d89aa953da45e747b7deb85266d0880 Mon Sep 17 00:00:00 2001 From: Adrian Zawadzki Date: Wed, 17 Jun 2026 21:50:55 +0200 Subject: [PATCH 3/4] fix(tests): cleanup --- python_tests/test_filter_stress.py | 86 +++++++++++++++++++++++++++++ python_tests/test_memo_no_cache.py | 3 - python_tests/test_memo_singleton.py | 53 ------------------ 3 files changed, 86 insertions(+), 56 deletions(-) create mode 100644 python_tests/test_filter_stress.py diff --git a/python_tests/test_filter_stress.py b/python_tests/test_filter_stress.py new file mode 100644 index 00000000..216d5a3a --- /dev/null +++ b/python_tests/test_filter_stress.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# Copyright (c) 2025 DBZero Software sp. z o.o. + +import gc +import tracemalloc + +import pytest +import dbzero as db0 + + +def _retained_bytes(before: tracemalloc.Snapshot, after: tracemalloc.Snapshot) -> int: + return sum( + stat.size_diff + for stat in after.compare_to(before, "lineno") + if stat.size_diff > 0 + ) + + +@pytest.mark.stress_test +def test_filter_find_pending_tasks_mock_jobs_does_not_leak_memory(db0_small_lang_cache_small_cache): + JobStatus = db0.enum( + "FilterLeakJobStatus", + ["READY", "WARMING_UP", "STARTED", "DONE"], + ) + + @db0.memo + class Job: + def __init__(self, name, status): + self.name = name + self.status = None + self.set_status(status) + + def set_status(self, status): + if self.status is not None: + db0.tags(self).remove(self.status) + db0.tags(self).add(status) + self.status = status + + active_statuses = [JobStatus.READY, JobStatus.WARMING_UP, JobStatus.STARTED] + all_statuses = [*active_statuses, JobStatus.DONE] + [Job(f"job-{i}", all_statuses[i % len(all_statuses)]) for i in range(128)] + jobs = list(db0.find(Job)) + pending_tasks = { + job: object() + for index, job in enumerate(jobs) + if job.status in active_statuses and index % 3 == 0 + } + expected = [ + job for job in jobs + if job.status in active_statuses and job not in pending_tasks + ] + + def run_query(): + ready_or_started_jobs = db0.filter( + lambda found_job: found_job not in pending_tasks, + db0.find(Job, [JobStatus.READY, JobStatus.WARMING_UP, JobStatus.STARTED]), + ) + return list(ready_or_started_jobs) + + for _ in range(50): + assert run_query() == expected + + gc.collect() + tracemalloc.start(25) + iterations = 200000 + try: + before = tracemalloc.take_snapshot() + for iteration in range(iterations): + assert run_query() == expected + if iteration % 50 == 49: + gc.collect() + if iteration % 1000 == 999: + print(f"Completed iteration {iteration + 1}/{iterations}...") + gc.collect() + after = tracemalloc.take_snapshot() + finally: + tracemalloc.stop() + + retained_bytes = _retained_bytes(before, after) + print(f"Retained {retained_bytes / (1024 * 1024):.3f} MB across 500 iterations of db0.filter(lambda found_job: found_job not in pending_tasks, " + f"db0.find(Job, [JobStatus.READY, JobStatus.WARMING_UP, JobStatus.STARTED]))\n Memory growth by iteration: {retained_bytes / iterations:.3f} bytes/iteration") + assert retained_bytes <= 512 * 1024, ( + "Repeated db0.filter(lambda found_job: found_job not in pending_tasks, " + "db0.find(Job, [JobStatus.READY, JobStatus.WARMING_UP, JobStatus.STARTED])) " + f"retained too much Python memory: {retained_bytes} bytes" + ) diff --git a/python_tests/test_memo_no_cache.py b/python_tests/test_memo_no_cache.py index 547c330d..f264e2b2 100644 --- a/python_tests/test_memo_no_cache.py +++ b/python_tests/test_memo_no_cache.py @@ -66,9 +66,6 @@ def test_excluding_no_cache_instances_from_P0_cache(db0_fixture): gc.collect() final_cache_size = db0.get_cache_stats()["P_size"]["P0"] # make sure cache utilization is low - print(f"Initial P0 cache size: {initial_cache_size}, Final P0 cache size: {final_cache_size}") - print(f"Difference in P0 cache size (KB): {(final_cache_size - initial_cache_size) / 1024:.3f} KB") - print(f"Needs to be less than 350 KB: {(350 << 10) / (1024):.3f} KB") assert abs(final_cache_size - initial_cache_size) < (400 << 10) diff --git a/python_tests/test_memo_singleton.py b/python_tests/test_memo_singleton.py index 5924ce2f..6ba0dbd2 100644 --- a/python_tests/test_memo_singleton.py +++ b/python_tests/test_memo_singleton.py @@ -3,7 +3,6 @@ import pytest import dbzero as db0 -from multiprocessing import Event, Process from .memo_test_types import MemoTestClass, MemoTestSingleton, MemoScopedSingleton, MemoDataPxSingleton from .memo_test_types import MemoSingletonWithMigrations @@ -11,27 +10,6 @@ from dbzero.memo import __dyn_prefix -@db0.memo(singleton=True) -class ExistingLockedPrefixSingleton: - """Singleton used only to create state on the locked prefix.""" - - -@db0.memo(singleton=True) -class MissingLockedPrefixSingleton: - """Singleton queried from the locked prefix but not created there.""" - - -def _hold_locked_prefix(dbzero_root: str, locked_prefix: str, ready: Event, stop: Event) -> None: - """Open a prefix in read-write mode and keep its dbzero lock held.""" - db0.init(dbzero_root, read_write=True) - db0.open(locked_prefix, "rw") - ExistingLockedPrefixSingleton() - db0.commit() - ready.set() - stop.wait(10) - db0.close() - - def test_memo_singleton_is_created_once(db0_fixture): object_1 = MemoTestSingleton(999, "text") object_2 = MemoTestSingleton() @@ -85,37 +63,6 @@ def test_find_singleton_static_scope(db0_fixture): assert db0.find_singleton(MemoDataPxSingleton) is obj_1 -@pytest.mark.skip(reason="issue-1395") -def test_find_singleton_on_missing_singleton_in_writer_locked_prefix_raises(tmp_path) -> None: - dbzero_root = str(tmp_path / "db0") - app_prefix = "app-prefix" - locked_prefix = "locked-prefix" - - db0.init(dbzero_root, read_write=True) - db0.open(app_prefix, "rw") - db0.commit() - db0.close() - - ready = Event() - stop = Event() - process = Process(target=_hold_locked_prefix, args=(dbzero_root, locked_prefix, ready, stop)) - process.start() - assert ready.wait(5) - try: - db0.init(dbzero_root, read_write=False) - db0.open(app_prefix, "r") - - singleton = db0.find_singleton(MissingLockedPrefixSingleton, locked_prefix) - assert singleton is not None - finally: - stop.set() - process.join(5) - if process.is_alive(): - process.terminate() - process.join() - db0.close() - - def test_find_singleton(db0_fixture): assert db0.find_singleton(MemoTestSingleton) is None # create on default prefix From fe6ce0fba9e91f2f89356c75fb822fac20426136 Mon Sep 17 00:00:00 2001 From: Adrian Zawadzki Date: Wed, 17 Jun 2026 21:52:18 +0200 Subject: [PATCH 4/4] fix(tests): removed redundant test --- python_tests/test_filter_stress.py | 86 ------------------------------ 1 file changed, 86 deletions(-) delete mode 100644 python_tests/test_filter_stress.py diff --git a/python_tests/test_filter_stress.py b/python_tests/test_filter_stress.py deleted file mode 100644 index 216d5a3a..00000000 --- a/python_tests/test_filter_stress.py +++ /dev/null @@ -1,86 +0,0 @@ -# SPDX-License-Identifier: LGPL-2.1-or-later -# Copyright (c) 2025 DBZero Software sp. z o.o. - -import gc -import tracemalloc - -import pytest -import dbzero as db0 - - -def _retained_bytes(before: tracemalloc.Snapshot, after: tracemalloc.Snapshot) -> int: - return sum( - stat.size_diff - for stat in after.compare_to(before, "lineno") - if stat.size_diff > 0 - ) - - -@pytest.mark.stress_test -def test_filter_find_pending_tasks_mock_jobs_does_not_leak_memory(db0_small_lang_cache_small_cache): - JobStatus = db0.enum( - "FilterLeakJobStatus", - ["READY", "WARMING_UP", "STARTED", "DONE"], - ) - - @db0.memo - class Job: - def __init__(self, name, status): - self.name = name - self.status = None - self.set_status(status) - - def set_status(self, status): - if self.status is not None: - db0.tags(self).remove(self.status) - db0.tags(self).add(status) - self.status = status - - active_statuses = [JobStatus.READY, JobStatus.WARMING_UP, JobStatus.STARTED] - all_statuses = [*active_statuses, JobStatus.DONE] - [Job(f"job-{i}", all_statuses[i % len(all_statuses)]) for i in range(128)] - jobs = list(db0.find(Job)) - pending_tasks = { - job: object() - for index, job in enumerate(jobs) - if job.status in active_statuses and index % 3 == 0 - } - expected = [ - job for job in jobs - if job.status in active_statuses and job not in pending_tasks - ] - - def run_query(): - ready_or_started_jobs = db0.filter( - lambda found_job: found_job not in pending_tasks, - db0.find(Job, [JobStatus.READY, JobStatus.WARMING_UP, JobStatus.STARTED]), - ) - return list(ready_or_started_jobs) - - for _ in range(50): - assert run_query() == expected - - gc.collect() - tracemalloc.start(25) - iterations = 200000 - try: - before = tracemalloc.take_snapshot() - for iteration in range(iterations): - assert run_query() == expected - if iteration % 50 == 49: - gc.collect() - if iteration % 1000 == 999: - print(f"Completed iteration {iteration + 1}/{iterations}...") - gc.collect() - after = tracemalloc.take_snapshot() - finally: - tracemalloc.stop() - - retained_bytes = _retained_bytes(before, after) - print(f"Retained {retained_bytes / (1024 * 1024):.3f} MB across 500 iterations of db0.filter(lambda found_job: found_job not in pending_tasks, " - f"db0.find(Job, [JobStatus.READY, JobStatus.WARMING_UP, JobStatus.STARTED]))\n Memory growth by iteration: {retained_bytes / iterations:.3f} bytes/iteration") - assert retained_bytes <= 512 * 1024, ( - "Repeated db0.filter(lambda found_job: found_job not in pending_tasks, " - "db0.find(Job, [JobStatus.READY, JobStatus.WARMING_UP, JobStatus.STARTED])) " - f"retained too much Python memory: {retained_bytes} bytes" - )