Skip to content
Draft
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
277 changes: 277 additions & 0 deletions src/seriousdb/b_plus_tree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
"""b+tree implementation for datalookups in O(log n).

this module will hold the b+tree datastructure for loading and deleting data in and out of memory
!IMPORTANT this implementation is not Threadsafe

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldnt it be good to make it threadsafe, to make it able to be multi threaded?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes it would be good haha one thing after another i was thinking about maby writing the datastructure not safe and have another class called smth like ConcurrentBPlusTree that just functions as a wrapper with locks instead of programming all of that into one class. maby multible strategys ( tryPop, Waitpop) idk i need to think about it

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I actually like the wrapper idea. Keeping the base BPlusTree thread-unsafe avoids unnecessary overhead and complexity for single-threaded use, while a ConcurrentBPlusTree could handle synchronization separatelysingle lock around oper. I'd probably start with a ations to keep it correct and simple, then consider read/write or finer-grained locking later if performance calls for it. waitPop could use a condition variable if you want blocking queue semantics too.
I would just like to have it added in this PR here. I dont really care where you put it, imo its just needed to be added here - this is your PR so maybe you think about doing such a thing. Nevertheless, this is a good PR.

"""

from __future__ import annotations


class _Node:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quick question we already have Node data class defined in storage_format.py and PageSerializer only accepts that type. I would rather you use the already defined type. if this just a prototype to test the algorithm if so please change before submitting this pr for review.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay i can use this but to be honest i dont like to use the same class for internal and leafnodes and differeantiating only via a boolean. i think 2 classes is way cleaner. But ey ur the boss please awnser to this and i just use the Node class in storage_format.py or maby alter it and also a internalNode class or smth

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well if you want you can abstract the base of the Node class and make two distinct ones for leaf and internals. But this would mean fixing the usage in the Serializer. I am not sure either. But you can keep using your own for now. I will see if I can push modification to abstract it later

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sowashieristjaschoninteressant
Hey! Good news, abstraction is merged so you get the two-class design you wanted. storage_format.py now has a separate LeafNode and InternalNode, plus Node as a union alias of the two. PageSerializer accepts both, so you can drop your own _Node / _LeafNode / _InternalNode and use these directly. isinstance(node, LeafNode) works the same way as in your current code.

Some things will look a bit different from your code:

  • Page ids instead of pointers. Children and the leaf chain are referenced by id (children_ids, next_page_id). So the tree needs some way to go from an id to a node (a plain dict is fine for now, we can swap in a pager later) and a way to allocate new page ids on splits. Page 0 is reserved for metadata, so ids start at 1.
  • No _parent. Nodes don't store one. Keep the path while you descend (e.g. a stack of nodes and child indexes) and walk back up that when a split has to propagate.
  • Child layout. leftmost_child_id is the child for keys below keys[0], and children_ids[i] is the child for keys >= keys[i]. The serializer expects len(keys) == len(children_ids). all_children_ids() gives you the full list, so if you use bisect index lines up with it. On an internal split, the left half keeps its leftmost child, and the right child of the middle key becomes the new node's leftmost_child_id. friendly wanrnin: That's the spot where off-by-one bugs usually happen.
  • Keys are bytes. Comparison and bisect still work, you just need to change the type hints.

The serializer also raises a SerializationError if a node doesn't fit in one page. Splitting by key count is fine while you're testing the algorithm, but we'll want a size-based check eventually.

Your split logic (copy the separator up for leaves, move it up for internals) carries over as is, only the pointer handling changes. Ping me if the serializer gets in your way.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes saw it im refactoring my tree! thx

"""internal class representing a node from the b+Tree."""

_parent: _InternalNode | None
_keys: list[int]

def __init__(self) -> None:
self._parent = None
self._keys = []


class _LeafNode(_Node):
"""internal class representing a _leafnode from the b+Tree."""

_values: list[bytes]
_next_leaf: _LeafNode | None

def __init__(self) -> None:
super().__init__()
self._values = []
self._next_leaf = None


class _InternalNode(_Node):
"""internal class representing."""

_children: list[_Node]

def __init__(self) -> None:
super().__init__()
self._children = []


class BPlusTree:
"""a Btree like Datastructure to retrieve and put data in O(log n) time.

the public functions will firstly be get() put() delete() range()
Comment thread
mattsva marked this conversation as resolved.

!IMPORTANT: this structure is currently not threadsafe.

Parameters
----------
_maxChildren :int
the tree takes in the maximum number of children a internal node should have.

Attributes
----------
_maxChildren : int
The maximum number of children (mostly in literature visualized as m) an internal Node can point to
_maxKeys: int
The maximum number of Keys a _Node can hold derrived from the maximum number of children (m -1)
_root : _Node or None
the root Node of the tree the first will be a leafNode and after enough insertions mostly an internal Node

Raises
------
ValueError if the maxchildren parameter is below 3
"""

_maxChildren: int = 0
_maxKeys: int = 0
_root: _LeafNode | _InternalNode | None
_size: int = 0

def __init__(self, maxChildren: int) -> None:
if maxChildren < 3:
raise ValueError("maxChildren must be at least 3")
self._maxChildren = maxChildren
Comment thread
sowashieristjaschoninteressant marked this conversation as resolved.
self._maxKeys = self._maxChildren - 1
self._root = None
self._size = 0

def put(self, key: int, value: bytes) -> bool:
"""Put an key value pair into the tree.

if the tree is empty a leafNode is created at root and filled with the first key and value.
if the tree alereay has some nodes the search starts. The next node from the children gets chosen via the comparator keys.
If a leafnode has to many values or keys put splits the leafnote.

Parameters
----------
key : int
the key to access the value.
value : bytes
bytes (Pages) to store.

Returns
-------
is_new_key : bool
``True`` if `key` did not exist before, ``False`` if an existing
value was replaced.
"""
is_new_key = True
self._size += 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the key already exists, you return False, but the _size gets still increased.
I dont think that this is the right behavior.

if self._root is None:
self._root = self._createLeaf(key, value)
return is_new_key

node: _LeafNode | _InternalNode | _Node = self._root
while True:
if isinstance(node, _LeafNode):
if self._isKeyInLeaf(node, key):
key_index = node._keys.index(key)
node._values[key_index] = value
return False

# NOTE: this find_position call only works because we check for duplicates in the if case before maby for later its more smart to make 2 helpers out of this
insert_index: int = self._find_position(node._keys, key)

node._keys.insert(insert_index, key)
node._values.insert(insert_index, value)

self._check_split(node)
return True
elif isinstance(node, _InternalNode):
internalNode: _InternalNode = node
nodeIndex = self._find_position(internalNode._keys, key)
node = internalNode._children[nodeIndex]

def get(self, key: int) -> bytes | None:
"""Return a value from the given key. if the key does not exist in the tree this function returns None.

Parameters
----------
key : int
the key to access the value.

Returns
-------
value : bytes | None
returns bytes if the key is in the tree otherwise this function returns None
"""
value: bytes | None = None
if self._root is None:
return value

node: _InternalNode | _LeafNode | _Node = self._root
while isinstance(node, _InternalNode):
nodeIndex: int = self._find_position(node._keys, key)
node = node._children[nodeIndex]

# node should always be a _LeafNode this might be to defensive
if isinstance(node, _LeafNode) and key in node._keys:
keyIndex: int = node._keys.index(key)
value = node._values[keyIndex]

return value

def size(self) -> int:
"""Return the current keyvaluePairs in the tree.

Returns
-------
value : int
returns the number of keyvalue pairs in the tree
"""
return self._size

def delete(self, key: int) -> bool:
"""Delete algorithm deletes key value pair."""
return True
Comment thread
mattsva marked this conversation as resolved.

"""------ INTERNAL FUNCTIONS ------"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a docstring is a no-op expression, so use a comment instead.


def _createLeaf(self, key: int, value) -> _LeafNode:
node = _LeafNode()
node._keys.append(key)
key_index = node._keys.index(key)
node._values.insert(key_index, value)
return node

def _createInternal(self, key, child1, child2) -> _InternalNode:
internalNode: _InternalNode = _InternalNode()
internalNode._keys.append(key)
internalNode._children.append(child1)
internalNode._children.append(child2)

return internalNode

def _isKeyInLeaf(self, node: _LeafNode, key: int) -> bool:
return key in node._keys

def _find_position(self, keys: list[int], key: int) -> int:
insert_index: int = 0

while insert_index < len(keys) and keys[insert_index] <= key:
Comment thread
sowashieristjaschoninteressant marked this conversation as resolved.
insert_index += 1

return insert_index

def _split_leaf(self, node: _LeafNode) -> None:
newLeaf: _LeafNode = _LeafNode()
keyLen: int = len(node._keys)
splitIndex: int = keyLen // 2

newLeaf._keys = node._keys[splitIndex:]
newLeaf._values = node._values[splitIndex:]

node._keys = node._keys[:splitIndex]
node._values = node._values[:splitIndex]

newLeaf._next_leaf = node._next_leaf
node._next_leaf = newLeaf

newSeperator: int = newLeaf._keys[0]

if node._parent is None:
# if the parent of an leafnote is None it is guaranteed that the leafnote lies at root.
internalNode: _InternalNode = self._createInternal(
newSeperator, node, newLeaf
)

newLeaf._parent = internalNode
node._parent = internalNode
self._root = internalNode
else:
parent: _InternalNode = node._parent

child_index: int = parent._children.index(node)
parent._children.insert(child_index + 1, newLeaf)
parent._keys.insert(child_index, newSeperator)
newLeaf._parent = parent

self._check_split(parent)

def _split_internal(self, node: _InternalNode) -> None:
oldNode = node
newNode = _InternalNode()

keyLen: int = len(node._keys)
splitIndex: int = keyLen // 2

tempKey = oldNode._keys[splitIndex]
newNode._keys = oldNode._keys[splitIndex + 1 :]
oldNode._keys = oldNode._keys[:splitIndex]

newNode._children = oldNode._children[splitIndex + 1 :]
oldNode._children = oldNode._children[: splitIndex + 1]

for child in newNode._children:
child._parent = newNode

if oldNode._parent is None:
internalNode: _InternalNode = self._createInternal(
tempKey, oldNode, newNode
)

newNode._parent = internalNode
oldNode._parent = internalNode
self._root = internalNode
else:
parent: _InternalNode = oldNode._parent

child_index: int = parent._children.index(oldNode)
parent._children.insert(child_index + 1, newNode)
parent._keys.insert(child_index, tempKey)
newNode._parent = parent

self._check_split(parent)

def _check_split(self, node: _Node | _InternalNode | _LeafNode) -> None:
if len(node._keys) <= self._maxKeys:
return

if isinstance(node, _LeafNode):
self._split_leaf(node)
elif isinstance(node, _InternalNode):
self._split_internal(node)
95 changes: 95 additions & 0 deletions tests/test_b_plus_tree.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the stress test only inserts ascending keys, so it only exercises right-edge splits. Please add descending and shuffled (seeded) inserts, and read every key bakc afterwards.

Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""tests for the b+Tree implementation"""

from seriousdb.b_plus_tree import BPlusTree, _InternalNode, _LeafNode, _Node


def assertTreeStructure(tree: BPlusTree) -> None:
root = tree._root

if root is None:
return
# okay here i will just make it a really long for loop so it cannot run infenetly if there is an error in the tree code
stack = [root]
while len(stack) > 0:
node: _Node = stack.pop()

assert len(node._keys) <= tree._maxKeys

if node is not tree._root:
assert node._parent is not None

if isinstance(node, _LeafNode):
assert len(node._keys) == len(node._values)

if isinstance(node, _InternalNode):
assert len(node._children) == len(node._keys) + 1
stack.extend(node._children)


def test_put_first_key():
maxChildren: int = 3
tree: BPlusTree = BPlusTree(maxChildren)

result = tree.put(1, b"2")

assert result is True


def test_put_same_key():
maxChildren: int = 3
tree: BPlusTree = BPlusTree(maxChildren)

tree.put(1, b"2")
result: bool = tree.put(1, b"2")

assert result is False


def test_force_split():
maxChildren: int = 3
tree: BPlusTree = BPlusTree(maxChildren)
tree.put(1, b"AA")
tree.put(2, b"AA")
tree.put(3, b"AA")
tree.put(4, b"AA")

assertTreeStructure(tree)
assert isinstance(tree._root, _InternalNode)


def test_stress_put():
maxChildren: int = 3
tree: BPlusTree = BPlusTree(maxChildren)
for i in range(0, 100000, 1):
tree.put(i, b"bbb")

assertTreeStructure(tree)


def test_get_success():
maxChildren: int = 3
tree: BPlusTree = BPlusTree(maxChildren)

tree.put(1, b"AA")
value = tree.get(1)

assert value == b"AA"


def test_get_empyTree():
maxChildren: int = 3
tree: BPlusTree = BPlusTree(maxChildren)
# retrive
value = tree.get(1)
assert value == None


def test_get_KeyNotInTree():
maxChildren: int = 3
tree: BPlusTree = BPlusTree(maxChildren)

tree.put(2, b"AA")
tree.put(3, b"BB")

value = tree.get(5)
assert value == None
Loading