Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ All tests must pass before a change is considered complete.

- Python tests: `./scripts/run_tests.sh`
- C++ tests after a `-t` build: `./build/release/tests.x`
- During development, do not run stress tests by default; they are intentionally slow. Run focused tests specific to the feature or refactor being worked on before finalization.
- If any C++ source under the native/core part of the project was modified, also run the C++ test suite (do not rely on the Python tests alone to cover native changes).

Never mark a task done while tests are failing.
Expand Down
300 changes: 300 additions & 0 deletions python_tests/test_atomic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# Copyright (c) 2025 DBZero Software sp. z o.o.

import time
import gc
import random
import pytest
import dbzero as db0
from .memo_test_types import MemoTestClass, MemoTestSingleton, MemoScopedSingleton, MemoScopedClass
Expand Down Expand Up @@ -382,6 +384,62 @@ def test_atomic_stress_test_1(db0_no_autocommit):
print(f"Atomic operations completed: {count}")


@pytest.mark.stress_test
def test_nested_atomic_stress_test_1(db0_no_autocommit):
rng = random.Random(0xDB0A70)
buf = db0.list()
state = MemoTestClass({"counter": 0})
expected_count = 0

def make_payload(outer_index, group_index, level, item_index):
header = f"{outer_index}:{group_index}:{level}:{item_index}:"
return header + rand_string(4096 - len(header))

def append_items(outer_index, group_index, level, item_count):
for item_index in range(item_count):
buf.append(MemoTestClass(make_payload(outer_index, group_index, level, item_index)))
state.value["counter"] = state.value["counter"] + item_count
return item_count

def run_nested_block(outer_index, group_index, level):
mode = rng.choices(["commit", "cancel", "exception"], weights=[6, 2, 2], k=1)[0]
max_depth = rng.randint(2, 4)
item_count = rng.randint(1, 4)
committed_count = 0

try:
with db0.atomic() as atomic:
committed_count += append_items(outer_index, group_index, level, item_count)

if level < max_depth and rng.random() < 0.75:
committed_count += run_nested_block(outer_index, group_index, level + 1)

if mode == "cancel":
atomic.cancel()
return 0

if mode == "exception":
raise RuntimeError("nested atomic rollback")

return committed_count
except RuntimeError:
return 0

for outer_index in range(250):
outer_committed_count = 0
with db0.atomic():
outer_committed_count += append_items(outer_index, -1, 0, 40)

for group_index in range(10):
outer_committed_count += run_nested_block(outer_index, group_index, 1)

expected_count += outer_committed_count
print(f"Nested atomic operations completed: {outer_index + 1}")

assert len(buf) == expected_count
assert state.value["counter"] == expected_count


def test_atomic_deletion(db0_fixture):
obj = MemoTestClass(MemoTestClass(123))
dep_uuid = db0.uuid(obj.value)
Expand Down Expand Up @@ -490,3 +548,245 @@ def test_atomic_context_does_not_increase_state_num(db0_fixture):
state_1 = db0.get_state_num()
with db0.atomic():
assert db0.get_state_num() == state_1


def test_nested_atomic_cancel_reverts_only_nested_changes(db0_fixture):
object_1 = MemoTestClass(1)
with db0.atomic():
object_1.value += 10
try:
with db0.atomic():
object_1.value += 20
raise RuntimeError("nested failure")
except RuntimeError:
pass

assert object_1.value == 11


def test_nested_atomic_success_merges_into_parent(db0_fixture):
object_1 = MemoTestClass(1)
with db0.atomic():
object_1.value += 10
with db0.atomic():
object_1.value += 20

assert object_1.value == 31


def test_deep_nested_atomic_cancel_reverts_top_only(db0_fixture):
object_1 = MemoTestClass(1)
with db0.atomic():
object_1.value += 10
with db0.atomic():
object_1.value += 20
with db0.atomic() as atomic:
object_1.value += 30
atomic.cancel()

assert object_1.value == 31


def test_parent_cancel_reverts_successful_nested_atomic(db0_fixture):
object_1 = MemoTestClass(1)
with db0.atomic() as atomic:
object_1.value += 10
with db0.atomic():
object_1.value += 20
atomic.cancel()

assert object_1.value == 1


def test_nested_atomic_list_cancel_reverts_only_nested_changes(db0_fixture):
object_1 = MemoTestClass([1])
with db0.atomic():
object_1.value.append(2)
try:
with db0.atomic():
object_1.value.append(3)
raise RuntimeError("nested failure")
except RuntimeError:
pass
object_1.value.append(4)

assert list(object_1.value) == [1, 2, 4]


def test_nested_atomic_can_begin_after_grandchild_rollback_with_list_update(db0_fixture):
items = db0.list()
root = MemoTestClass({"items": items, "counter": 0})

with db0.atomic():
root.value["counter"] = 1
committed_child = MemoTestClass("child")
items.append(committed_child)

try:
with db0.atomic():
rolled_child = MemoTestClass("rolled")
root.value["counter"] = 999
items.append(rolled_child)

with db0.atomic():
root.value["counter"] = 1000
raise RuntimeError("grandchild rollback")
except RuntimeError:
pass

with db0.atomic():
root.value["counter"] = 2

assert root.value["counter"] == 2
assert [obj.value for obj in items] == ["child"]
root = items = committed_child = None
gc.collect()


def test_nested_atomic_rollback_of_new_tagged_object_is_gc_safe(db0_fixture):
items = db0.list()

with db0.atomic():
committed_child = MemoTestClass("child")
items.append(committed_child)

try:
with db0.atomic():
rolled_child = MemoTestClass("rolled")
items.append(rolled_child)
db0.tags(rolled_child).add("nested-rolled")

with db0.atomic():
raise RuntimeError("grandchild rollback")
except RuntimeError:
pass

assert [obj.value for obj in items] == ["child"]
assert len(list(db0.find("nested-rolled"))) == 0
items = committed_child = rolled_child = None
gc.collect()


def test_nested_atomic_cancel_reverts_index_add_without_corrupting_index(db0_fixture):
index = db0.index()
committed = MemoTestClass("committed")
canceled = None

with db0.atomic():
index.add(1, committed)

with db0.atomic() as atomic:
canceled = MemoTestClass("canceled")
index.add(2, canceled)
atomic.cancel()
canceled = None

assert [obj.value for obj in index.select(0, 10)] == ["committed"]

assert [obj.value for obj in index.select(0, 10)] == ["committed"]
index = committed = canceled = None
gc.collect()


def test_nested_atomic_rollback_preserves_parent_list_slab_metadata(db0_fixture):
items = db0.list()

with db0.atomic():
for i in range(20):
items.append(f"parent-before-{i}")

try:
with db0.atomic():
for i in range(20):
items.append(f"child-rollback-{i}")
raise RuntimeError("rollback child list writes")
except RuntimeError:
pass

for i in range(20):
items.append(f"parent-after-{i}")

assert list(items) == [
*(f"parent-before-{i}" for i in range(20)),
*(f"parent-after-{i}" for i in range(20)),
]
items = None
gc.collect()


def test_deep_nested_atomic_mixed_commit_and_rollback(db0_fixture):
items = db0.list()
index = db0.index()
keep = MemoTestClass("keep")
drop = MemoTestClass("drop")
root = MemoTestClass({"drop": drop, "counter": 0})
drop = None

items.append(keep)
index.add(1, keep)
db0.tags(keep).add("nested-keep")

with db0.atomic():
root.value["counter"] = 1
committed_child = MemoTestClass("child-commit")
items.append(committed_child)
index.add(2, committed_child)
db0.tags(committed_child).add("nested-child-commit")

try:
with db0.atomic():
nonlocal_marker = MemoTestClass("child-rollback")
root.value["counter"] = 999
root.value["drop"] = None
items.append(nonlocal_marker)
index.add(3, nonlocal_marker)
db0.tags(nonlocal_marker).add("nested-child-rollback")

with db0.atomic():
root.value["counter"] = 1000
raise RuntimeError("level 3 rollback")
except RuntimeError:
nonlocal_marker = None
pass

assert root.value["counter"] == 1
assert [obj.value for obj in items] == ["keep", "child-commit"]
assert {obj.value for obj in index.select(0, 10)} == {"keep", "child-commit"}
assert len(list(db0.find("nested-child-rollback"))) == 0
assert root.value["drop"].value == "drop"

with db0.atomic():
root.value["counter"] = 2
grandchild = MemoTestClass("grandchild-commit")
items.append(grandchild)
index.add(4, grandchild)
db0.tags(grandchild).add("nested-grandchild-commit")

assert root.value["counter"] == 2
assert [obj.value for obj in items] == [
"keep",
"child-commit",
"grandchild-commit",
]

root.value["drop"] = None

assert root.value["counter"] == 2
assert [obj.value for obj in items] == [
"keep",
"child-commit",
"grandchild-commit",
]
assert {obj.value for obj in index.select(0, 10)} == {
"keep",
"child-commit",
"grandchild-commit",
}
assert len(list(db0.find("nested-keep"))) == 1
assert len(list(db0.find("nested-child-commit"))) == 1
assert len(list(db0.find("nested-grandchild-commit"))) == 1
assert len(list(db0.find("nested-child-rollback"))) == 0
assert root.value["drop"] is None

root = items = index = keep = committed_child = grandchild = None
gc.collect()
Loading
Loading