WIP: caching
Run tests / run-tests (push) Successful in 2m3s
Run benchmarks / run-tests (push) Successful in 11m6s

This commit is contained in:
2026-01-08 13:52:52 +09:00
parent dab162d595
commit 4a10c08f47
13 changed files with 451 additions and 13 deletions
+3
View File
@@ -27,6 +27,9 @@ class Base(DeclarativeBase):
@declared_attr.directive @declared_attr.directive
def __tablename__(cls) -> str: def __tablename__(cls) -> str:
if hasattr(cls, "__table_name__"):
assert isinstance(cls.__table_name__, str)
return cls.__table_name__
return _pascal_case_to_snake_case(cls.__name__) return _pascal_case_to_snake_case(cls.__name__)
# NOTE: This is the default implementation of __repr__ for all tables, # NOTE: This is the default implementation of __repr__ for all tables,
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from dibbler.models import Base
if TYPE_CHECKING:
from dibbler.models import Transaction
class LastCacheTransaction(Base):
"""Tracks the last transaction that affected various caches."""
id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""Internal database ID"""
transaction_id: Mapped[int | None] = mapped_column(ForeignKey("trx.id"), index=True)
"""The ID of the last transaction that affected the cache(s)."""
transaction: Mapped[Transaction | None] = relationship(
lazy="joined",
foreign_keys=[transaction_id],
)
"""The last transaction that affected the cache(s)."""
+20 -7
View File
@@ -1,16 +1,29 @@
from datetime import datetime from typing import TYPE_CHECKING
from sqlalchemy import Integer, DateTime from sqlalchemy import Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from dibbler.models import Base from dibbler.models import Base
if TYPE_CHECKING:
from dibbler.models import LastCacheTransaction, Product
class ProductCache(Base): class ProductCache(Base):
product_id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""Internal database ID"""
product_id: Mapped[int] = mapped_column(ForeignKey('product.id'), primary_key=True)
product: Mapped[Product] = relationship(
lazy="joined",
foreign_keys=[product_id],
)
price: Mapped[int] = mapped_column(Integer) price: Mapped[int] = mapped_column(Integer)
price_timestamp: Mapped[datetime] = mapped_column(DateTime)
stock: Mapped[int] = mapped_column(Integer) stock: Mapped[int] = mapped_column(Integer)
stock_timestamp: Mapped[datetime] = mapped_column(DateTime)
last_cache_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("last_cache_transaction.id"), nullable=True)
last_cache_transaction: Mapped[LastCacheTransaction | None] = relationship(
lazy="joined",
foreign_keys=[last_cache_transaction_id],
)
+2 -1
View File
@@ -87,6 +87,7 @@ def _transaction_type_field_constraints(
class Transaction(Base): class Transaction(Base):
__tablename__ = "trx"
__table_args__ = ( __table_args__ = (
*[ *[
_transaction_type_field_constraints(transaction_type, expected_fields) _transaction_type_field_constraints(transaction_type, expected_fields)
@@ -210,7 +211,7 @@ class Transaction(Base):
""" """
joint_transaction_id: Mapped[int | None] = mapped_column( joint_transaction_id: Mapped[int | None] = mapped_column(
ForeignKey("transaction.id"), ForeignKey("trx.id"),
index=True, index=True,
) )
""" """
+20 -5
View File
@@ -1,14 +1,29 @@
from datetime import datetime from typing import TYPE_CHECKING
from sqlalchemy import Integer, DateTime from sqlalchemy import Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from dibbler.models import Base from dibbler.models import Base
if TYPE_CHECKING:
from dibbler.models import LastCacheTransaction, User
# More like user balance cash money flow, amirite? # More like user balance cash money flow, amirite?
class UserBalanceCache(Base): class UserBalanceCache(Base):
user_id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""internal database id"""
user_id: Mapped[int] = mapped_column(ForeignKey('user.id'), primary_key=True)
user: Mapped[User] = relationship(
lazy="joined",
foreign_keys=[user_id],
)
balance: Mapped[int] = mapped_column(Integer) balance: Mapped[int] = mapped_column(Integer)
timestamp: Mapped[datetime] = mapped_column(DateTime)
last_cache_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("last_cache_transaction.id"), nullable=True)
last_cache_transaction: Mapped[LastCacheTransaction | None] = relationship(
lazy="joined",
foreign_keys=[last_cache_transaction_id],
)
+2
View File
@@ -1,5 +1,6 @@
__all__ = [ __all__ = [
"Base", "Base",
"LastCacheTransaction",
"Product", "Product",
"Transaction", "Transaction",
"TransactionType", "TransactionType",
@@ -7,6 +8,7 @@ __all__ = [
] ]
from .Base import Base from .Base import Base
from .LastCacheTransaction import LastCacheTransaction
from .Product import Product from .Product import Product
from .Transaction import Transaction from .Transaction import Transaction
from .TransactionType import TransactionType from .TransactionType import TransactionType
+4
View File
@@ -4,6 +4,8 @@ __all__ = [
"adjust_interest", "adjust_interest",
"adjust_penalty", "adjust_penalty",
"adjust_stock", "adjust_stock",
"affected_products",
"affected_users",
"create_product", "create_product",
"create_user", "create_user",
"current_interest", "current_interest",
@@ -29,6 +31,8 @@ from .adjust_balance import adjust_balance
from .adjust_interest import adjust_interest from .adjust_interest import adjust_interest
from .adjust_penalty import adjust_penalty from .adjust_penalty import adjust_penalty
from .adjust_stock import adjust_stock from .adjust_stock import adjust_stock
from .affected_products import affected_products
from .affected_users import affected_users
from .create_product import create_product from .create_product import create_product
from .create_user import create_user from .create_user import create_user
from .current_interest import current_interest from .current_interest import current_interest
+88
View File
@@ -0,0 +1,88 @@
from datetime import datetime
from sqlalchemy import BindParameter, select
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, TransactionType
from dibbler.queries.query_helpers import until_filter, after_filter
def affected_products(
sql_session: Session,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: BindParameter[Transaction] | Transaction | None = None,
until_inclusive: bool = True,
after_time: BindParameter[datetime] | datetime | None = None,
after_transaction: Transaction | None = None,
after_inclusive: bool = True,
) -> set[Product]:
"""
Get all products where attributes were affected over a given interval.
"""
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = BindParameter("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
if not (after_time is None or after_transaction is None):
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
if isinstance(after_time, datetime):
after_time = BindParameter("after_time", value=after_time)
if isinstance(after_transaction, Transaction):
if after_transaction.id is None:
raise ValueError("after_transaction must be persisted in the database.")
after_transaction_id = BindParameter("after_transaction_id", value=after_transaction.id)
else:
after_transaction_id = None
if after_time is not None and until_time is not None:
assert isinstance(after_time.value, datetime)
assert isinstance(until_time.value, datetime)
if after_time.value > until_time.value:
raise ValueError("after_time cannot be after until_time.")
if after_transaction is not None and until_transaction is not None:
assert after_transaction.time is not None
assert until_transaction.time is not None
if after_transaction.time > until_transaction.time:
raise ValueError("after_transaction cannot be after until_transaction.")
result = sql_session.scalars(
select(Product)
.distinct()
.join(Transaction, Product.id == Transaction.product_id)
.where(
Transaction.type_.in_(
[
TransactionType.ADD_PRODUCT.as_literal_column(),
TransactionType.ADJUST_STOCK.as_literal_column(),
TransactionType.BUY_PRODUCT.as_literal_column(),
TransactionType.JOINT.as_literal_column(),
TransactionType.THROW_PRODUCT.as_literal_column(),
]
),
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
after_filter(
after_time=after_time,
after_transaction_id=after_transaction_id,
after_inclusive=after_inclusive,
),
)
.order_by(Transaction.time.desc())
).all()
return set(result)
+86
View File
@@ -0,0 +1,86 @@
from datetime import datetime
from sqlalchemy import BindParameter, select, or_
from sqlalchemy.orm import Session
from dibbler.models import Transaction, TransactionType, User
from dibbler.queries.query_helpers import until_filter, after_filter
def affected_users(
sql_session: Session,
until_time: BindParameter[datetime] | datetime | None = None,
until_transaction: BindParameter[Transaction] | Transaction | None = None,
until_inclusive: bool = True,
after_time: BindParameter[datetime] | datetime | None = None,
after_transaction: Transaction | None = None,
after_inclusive: bool = True,
) -> set[User]:
"""
Get all users where attributes were affected over a given interval.
"""
if isinstance(until_time, datetime):
until_time = BindParameter("until_time", value=until_time)
if isinstance(until_transaction, Transaction):
if until_transaction.id is None:
raise ValueError("until_transaction must be persisted in the database.")
until_transaction_id = BindParameter("until_transaction_id", value=until_transaction.id)
else:
until_transaction_id = None
if not (after_time is None or after_transaction is None):
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
if isinstance(after_time, datetime):
after_time = BindParameter("after_time", value=after_time)
if isinstance(after_transaction, Transaction):
if after_transaction.id is None:
raise ValueError("after_transaction must be persisted in the database.")
after_transaction_id = BindParameter("after_transaction_id", value=after_transaction.id)
else:
after_transaction_id = None
if after_time is not None and until_time is not None:
assert isinstance(after_time.value, datetime)
assert isinstance(until_time.value, datetime)
if after_time.value > until_time.value:
raise ValueError("after_time cannot be after until_time.")
if after_transaction is not None and until_transaction is not None:
assert after_transaction.time is not None
assert until_transaction.time is not None
if after_transaction.time > until_transaction.time:
raise ValueError("after_transaction cannot be after until_transaction.")
result = sql_session.scalars(
select(User)
.distinct()
.join(Transaction, or_(User.id == Transaction.user_id, User.id == Transaction.transfer_user_id))
.where(
Transaction.type_.in_(
[
TransactionType.ADD_PRODUCT.as_literal_column(),
TransactionType.ADJUST_BALANCE.as_literal_column(),
TransactionType.BUY_PRODUCT.as_literal_column(),
TransactionType.TRANSFER.as_literal_column(),
]
),
until_filter(
until_time=until_time,
until_transaction_id=until_transaction_id,
until_inclusive=until_inclusive,
),
after_filter(
after_time=after_time,
after_transaction_id=after_transaction_id,
after_inclusive=after_inclusive,
),
)
.order_by(Transaction.time.desc())
).all()
return set(result)
+39
View File
@@ -78,3 +78,42 @@ def until_filter(
) )
return CONST_TRUE return CONST_TRUE
def after_filter(
after_time: BindParameter[datetime] | None = None,
after_transaction_id: BindParameter[int] | None = None,
after_inclusive: bool = True,
transaction_time: QueryableAttribute = Transaction.time,
) -> ColumnExpressionArgument[bool]:
"""
Create a filter condition for transactions after a given time or transaction.
Only one of `after_time` or `after_transaction_id` may be specified.
"""
assert not (after_time is not None and after_transaction_id is not None), (
"Cannot filter by both after_time and after_transaction_id."
)
match (after_time, after_transaction_id, after_inclusive):
case (BindParameter(), None, True):
return transaction_time >= after_time
case (BindParameter(), None, False):
return transaction_time > after_time
case (None, BindParameter(), True):
return (
transaction_time
>= select(Transaction.time)
.where(Transaction.id == after_transaction_id)
.scalar_subquery()
)
case (None, BindParameter(), False):
return (
transaction_time
> select(Transaction.time)
.where(Transaction.id == after_transaction_id)
.scalar_subquery()
)
return CONST_TRUE
+13
View File
@@ -0,0 +1,13 @@
def update_cache(
use_cache: bool = True,
) -> None:
"""
Update the cache used for searching products.
If `use_cache` is False, the cache will be rebuilt from scratch.
"""
# TODO:
# get last transaction
# get last cache update transaction
# get affected users and products since last cache update
# update cache for affected users and products
+74
View File
@@ -0,0 +1,74 @@
from datetime import datetime, timedelta
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
from dibbler.queries import affected_products
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
def insert_test_data(sql_session: Session) -> tuple[User, list[Product]]:
user = User("Test User")
products = []
for i in range(10):
product = Product(f"12345678901{i:02d}", f"Test Product {i}")
products.append(product)
sql_session.add(user)
sql_session.add_all(products)
sql_session.commit()
return user, products
def test_affected_products_no_history(sql_session: Session) -> None:
insert_test_data(sql_session)
result = affected_products(sql_session)
assert result == set()
def test_affected_products_basic_history(sql_session: Session) -> None:
user, products = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
amount=10,
per_product=10,
user_id=user.id,
product_id=products[i].id,
product_count=1,
)
for i in range(5)
] + [
Transaction.buy_product(
user_id=user.id,
product_id=products[i].id,
product_count=1,
)
for i in range(3)
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
result = affected_products(sql_session)
expected_products = {products[i] for i in range(5)}
assert result == expected_products
# def test_affected_products_after(sql_session: Session) -> None:
# def test_affected_products_until(sql_session: Session) -> None:
# def test_affected_products_after_until(sql_session: Session) -> None:
# def test_affected_products_after_inclusive(sql_session: Session) -> None:
# def test_affected_products_until_inclusive(sql_session: Session) -> None:
# def test_affected_products_after_until_inclusive(sql_session: Session) -> None:
+74
View File
@@ -0,0 +1,74 @@
from datetime import datetime, timedelta
import pytest
from sqlalchemy.orm import Session
from dibbler.models import Product, Transaction, User
from dibbler.queries import affected_users
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
def insert_test_data(sql_session: Session) -> tuple[list[User], Product]:
users = []
for i in range(10):
user = User(f"Test User {i + 1}")
users.append(user)
product = Product("1234567890123", "Test Product")
sql_session.add_all(users)
sql_session.add(product)
sql_session.commit()
return users, product
def test_affected_users_no_history(sql_session: Session) -> None:
insert_test_data(sql_session)
result = affected_users(sql_session)
assert result == set()
def test_affected_users_basic_history(sql_session: Session) -> None:
users, product = insert_test_data(sql_session)
transactions = [
Transaction.add_product(
amount=10,
per_product=10,
user_id=users[i].id,
product_id=product.id,
product_count=1,
)
for i in range(5)
] + [
Transaction.buy_product(
user_id=users[i].id,
product_id=product.id,
product_count=1,
)
for i in range(3)
]
assign_times(transactions)
sql_session.add_all(transactions)
sql_session.commit()
assert_id_order_similar_to_time_order(transactions)
result = affected_users(sql_session)
expected_users = {users[i] for i in range(5)}
assert result == expected_users
# def test_affected_users_after(sql_session: Session) -> None:
# def test_affected_users_until(sql_session: Session) -> None:
# def test_affected_users_after_until(sql_session: Session) -> None:
# def test_affected_users_after_inclusive(sql_session: Session) -> None:
# def test_affected_users_until_inclusive(sql_session: Session) -> None:
# def test_affected_users_after_until_inclusive(sql_session: Session) -> None: