Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@ All tests must pass before a change is considered complete.
- Debug build: `./scripts/build.sh -d` (equivalent to `./scripts/build.sh`; debug is the default)
- Release build: `./scripts/build.sh -r`
- Release build with C++ unit test binary: `./scripts/build.sh -r -t`
- For regular development and focused Python-side test runs, prefer the release build because it is significantly faster. Unless C++ tests are important for the current change, build without `-t`; skipping the C++ test binary is much faster.
- Use a debug build when tracking a specific deep bug that needs assertions or debug-level checks, and before final handoff only when the user explicitly asks for handoff validation.

### Running tests

- Python tests: `./scripts/run_tests.sh`
- Final Python test checks: `./scripts/run_tests.sh -j 6`
- C++ tests after a `-t` build: `./build/release/tests.x`
- Broad debug/release builds and full-suite checks are final handoff validation only; run them when the user explicitly asks for handoff.
- Before final handoff, also verify the code in debug mode with a debug build (`./scripts/build.sh -d`) and the relevant Python tests against that debug build. Debug assertions are part of the required validation, not optional diagnostics.
- 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).
- If any C++ source under the native/core part of the project was modified, also run the C++ test suite during final handoff validation (do not rely on the Python tests alone to cover native changes).

Never mark a task done while tests are failing.

Expand Down
96 changes: 96 additions & 0 deletions benchmarks/read_only_reads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Benchmark regular db0 memo reads with read_only machinery compiled in.

This benchmark intentionally performs only plain db0 read operations outside a
read_only block. It answers whether the read_only mutation-check machinery
slows existing read-heavy code when read_only is not active.

Observed on this workspace:
- CPU: 11th Gen Intel(R) Core(TM) i9-11950H @ 2.60GHz (2.61 GHz)
- Python: 3.11.13
- Build: release, default async-safe read_only implementation, mutation-only atomic API guard
- Command:
PYTHONPATH=/src/dev/dbzero python3 benchmarks/read_only_reads.py --target-seconds 30
- Current result:
iterations=55059079
elapsed_seconds=30.433811
reads_per_second=1809141.757
nanoseconds_per_read=552.748
- Previous recorded result:
iterations=56095010
elapsed_seconds=30.795000
reads_per_second=1821562.283
nanoseconds_per_read=548.979
"""

import argparse
import gc
import tempfile
import time

import dbzero as db0


@db0.memo
class ReadBenchmarkMemo:
pass


def run_reads(obj, iterations):
total = 0
for _ in range(iterations):
total += obj.value
return total


def measure(obj, iterations):
gc_was_enabled = gc.isenabled()
gc.disable()
try:
start = time.perf_counter()
total = run_reads(obj, iterations)
elapsed = time.perf_counter() - start
finally:
if gc_was_enabled:
gc.enable()
return elapsed, total


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--iterations", type=int)
parser.add_argument("--target-seconds", type=float, default=30.0)
parser.add_argument("--calibration-seconds", type=float, default=2.0)
args = parser.parse_args()

with tempfile.TemporaryDirectory() as root:
db0.init(root)
db0.open("read-only-overhead-benchmark")
obj = ReadBenchmarkMemo()
obj.value = 1
object_id = db0.uuid(obj)
db0.commit()
obj = db0.fetch(object_id)

run_reads(obj, 10000)
if args.iterations is None:
calibration_iterations = 10000
elapsed = 0.0
while elapsed < args.calibration_seconds:
calibration_iterations *= 2
elapsed, _ = measure(obj, calibration_iterations)
iterations = max(1, int(calibration_iterations * args.target_seconds / elapsed))
else:
iterations = args.iterations

elapsed, total = measure(obj, iterations)
print(f"build_flags={db0.build_flags()}")
print(f"iterations={iterations}")
print(f"elapsed_seconds={elapsed:.6f}")
print(f"reads_per_second={iterations / elapsed:.3f}")
print(f"nanoseconds_per_read={elapsed * 1_000_000_000 / iterations:.3f}")
print(f"checksum={total}")


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions dbzero/dbzero/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .storage_api import *
from .atomic import *
from .locked import *
from .read_only import *
from .utilities import taggify
from .decorators import *
from .select import *
Expand Down
118 changes: 116 additions & 2 deletions dbzero/dbzero/atomic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,47 @@

from __future__ import annotations

from typing import Any, Dict
import asyncio
import contextvars
import threading
import weakref
from dataclasses import dataclass
from typing import Any, Dict, Optional
from .interfaces import Memo
from .dbzero import begin_atomic, assign
from .dbzero import begin_atomic, begin_async_atomic, assign


_async_atomic_locks: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Lock] = weakref.WeakKeyDictionary()
_async_atomic_locks_guard = threading.Lock()


@dataclass(frozen=True)
class _AsyncAtomicState:
owner_task: asyncio.Task
depth: int
lock: asyncio.Lock


_async_atomic_state: contextvars.ContextVar[Optional[_AsyncAtomicState]] = contextvars.ContextVar(
"dbzero_async_atomic_state",
default=None,
)


def _current_async_task() -> Optional[asyncio.Task]:
try:
return asyncio.current_task()
except RuntimeError:
return None


def _get_async_atomic_lock(loop: asyncio.AbstractEventLoop) -> asyncio.Lock:
with _async_atomic_locks_guard:
lock = _async_atomic_locks.get(loop)
if lock is None:
lock = asyncio.Lock()
_async_atomic_locks[loop] = lock
return lock


class AtomicManager:
Expand Down Expand Up @@ -107,6 +145,82 @@ def atomic() -> AtomicManager:
return AtomicManager()


class AsyncAtomicManager:
"""Async context manager for dbzero atomic operations in asyncio tasks."""

def __init__(self):
self.__ctx = None
self.__state_token = None
self.__lock = None
self.__release_lock = False

async def __aenter__(self) -> AsyncAtomicManager:
task = _current_async_task()
if task is None:
raise RuntimeError("db0.async_atomic requires a running asyncio task")

state = _async_atomic_state.get()
if state is not None and state.owner_task is task:
self.__lock = state.lock
next_state = _AsyncAtomicState(task, state.depth + 1, state.lock)
else:
loop = asyncio.get_running_loop()
self.__lock = _get_async_atomic_lock(loop)
await self.__lock.acquire()
self.__release_lock = True
next_state = _AsyncAtomicState(task, 1, self.__lock)

try:
self.__ctx = begin_async_atomic()
except BaseException:
if self.__release_lock and self.__lock is not None:
self.__lock.release()
self.__release_lock = False
self.__lock = None
raise

self.__state_token = _async_atomic_state.set(next_state)
return self

async def __aexit__(self, exc_type, exc_value, traceback):
try:
if exc_type is None:
self.close()
else:
self.cancel()
finally:
if self.__state_token is not None:
_async_atomic_state.reset(self.__state_token)
self.__state_token = None
if self.__release_lock and self.__lock is not None:
self.__lock.release()
self.__release_lock = False
self.__lock = None

def close(self):
"""Close the atomic context, staging the changes for commit."""
if self.__ctx is None:
return

self.__ctx.close()
self.__ctx = None

def cancel(self):
"""Cancel the atomic context, reverting all changes."""
if self.__ctx is None:
return

self.__ctx.cancel()
self.__ctx = None


def async_atomic() -> AsyncAtomicManager:
"""Open an asyncio-aware atomic context manager for dbzero operations."""
if _current_async_task() is None:
raise RuntimeError("db0.async_atomic requires a running asyncio task")
return AsyncAtomicManager()


def atomic_assign(*objects: Memo, **attributes: Dict[str, Any]) -> None:
"""Perform bulk attribute updates on one or more Memo objects within an atomic transaction.

Expand Down
2 changes: 1 addition & 1 deletion dbzero/dbzero/dbzero.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/debug", "/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):
Expand Down
6 changes: 5 additions & 1 deletion dbzero/dbzero/dbzero.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Type stubs for dbzero module.
"""

from typing import Any, Optional, Iterable, Dict, List, Tuple, Union, Callable, Sequence
from typing import Any, Optional, Iterable, Dict, List, Tuple, Union, Callable, Sequence, ContextManager
from .interfaces import (
Memo, MemoWeakProxy, QueryObject, Tag, TagSet, EnumValue,
ListObject, IndexObject, TupleObject, SetObject, DictObject, ByteArrayObject,
Expand All @@ -11,6 +11,10 @@ from .interfaces import (

# Core workspace management functions

def read_only() -> ContextManager[Any]:
"""Open a context manager that rejects dbzero mutations in its block."""
...

def open(prefix_name: str, open_mode: str = "rw", **kwargs: Any) -> None:
"""Open a data prefix and set it as the current working context.

Expand Down
27 changes: 27 additions & 0 deletions dbzero/dbzero/read_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# Copyright (c) 2025 DBZero Software sp. z o.o.

from __future__ import annotations

from .dbzero import begin_read_only


class ReadOnlyManager:
"""Context manager that rejects dbzero mutations while active."""

def __init__(self):
self.__ctx = None

def __enter__(self) -> ReadOnlyManager:
self.__ctx = begin_read_only()
return self

def __exit__(self, _exc_type, _exc_value, _traceback):
if self.__ctx is not None:
self.__ctx.close()
self.__ctx = None


def read_only() -> ReadOnlyManager:
"""Open a context manager that rejects dbzero mutations in its block."""
return ReadOnlyManager()
Loading
Loading