diff --git a/.gitea/workflows/benchmark.yaml b/.gitea/workflows/benchmark.yaml new file mode 100644 index 0000000..76df893 --- /dev/null +++ b/.gitea/workflows/benchmark.yaml @@ -0,0 +1,71 @@ +name: Run benchmarks +on: + workflow_dispatch: + # TODO: make this only workflow_dispatch when merged into main + push: + +jobs: + run-tests: + runs-on: debian-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync --locked --group test + + - name: Run benchmarks + continue-on-error: true + run: | + set -euo pipefail + set -x + + PYTEST_ARGS=( + -vv + + --benchmark-only + -k test_benchmark + ) + + uv run -- pytest "${PYTEST_ARGS[@]}" + + - name: Upload benchmark JSON report + uses: https://git.pvv.ntnu.no/Projects/rsync-action@v2 + with: + source: ./benchmark/*/*.json + quote-source: false + target: ${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/benchmark.json + username: gitea-web + ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }} + host: pages.pvv.ntnu.no + known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg" + + - name: Upload histograms + uses: https://git.pvv.ntnu.no/Projects/rsync-action@v2 + with: + source: ./benchmark/*.svg + quote-source: false + target: ${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/ + username: gitea-web + ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }} + host: pages.pvv.ntnu.no + known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg" + + # NOTE: $GITHUB_STEP_SUMMARY when... + - name: Run information + run: | + echo "Benchmark run ID: ${{ github.run_id }}" + echo "Benchmark JSON: https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/benchmark.json" + echo "Histograms: https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_owners.svg" + echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_price.svg" + echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_stock.svg" + echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-transaction_log.svg" + echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-user_balance.svg" + + - name: Check failure + if: failure() + run: | + echo "Tests failed" + exit 1 diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml index fa9629f..c73e531 100644 --- a/.gitea/workflows/test.yaml +++ b/.gitea/workflows/test.yaml @@ -30,22 +30,8 @@ jobs: set -euo pipefail set -x - PYTEST_ARGS=( - -vv - - --cov=dibbler.lib - --cov=dibbler.models - --cov=dibbler.queries - --cov-report=html - --cov-branch - - --self-contained-html - --html=./test-report/index.html - ) - - if [ "$DEBUG_SQL" == "true" ]; then - PYTEST_ARGS+=( - --debug-sql + PYTEST_ARGS=( + -vv ) if [ "$DEBUG_SQL" == "true" ]; then diff --git a/.gitignore b/.gitignore index 67feb71..1fab83d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,7 @@ test.db dibbler/_version.py .coverage +.coverage.* htmlcov test-report +/benchmark diff --git a/nix/shell.nix b/nix/shell.nix index 7654d55..6c91670 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -20,6 +20,8 @@ mkShell { pytest pytest-cov pytest-html + pytest-benchmark + pygal ])) ]; } diff --git a/pyproject.toml b/pyproject.toml index 5d56594..0ee5367 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ test = [ "coverage-badge>=1.1.2", "pytest-html>=4.1.1", "sqlparse>=0.5.4", + "pytest-benchmark[histogram]>=5.2.3", ] [tool.setuptools.packages.find] @@ -78,3 +79,22 @@ ignore = [ [tool.ruff.lint.flake8-annotations] suppress-dummy-args = true ignore-fully-untyped = true + +[tool.pytest.ini_options] +addopts = [ + "--cov=dibbler.lib", + "--cov=dibbler.models", + "--cov=dibbler.queries", + "--cov-report=html", + "--cov-branch", + + "--self-contained-html", + "--html=./test-report/index.html", + + "--benchmark-skip", + "--benchmark-autosave", + "--benchmark-save=default", + "--benchmark-verbose", + "--benchmark-storage=benchmark", + "--benchmark-histogram=benchmark/histogram", +] diff --git a/tests/benchmark/__init__.py b/tests/benchmark/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/benchmark/benchmark_settings.py b/tests/benchmark/benchmark_settings.py new file mode 100644 index 0000000..ca13694 --- /dev/null +++ b/tests/benchmark/benchmark_settings.py @@ -0,0 +1,11 @@ +TRANSACTION_GENERATOR_EXCEPTION_LIMIT = 15 +""" +The random transaction generator uses a set seed to generate transactions. +However, not all transactions are valid in all contexts. We catch illegal +generated transactions with a try/except, and retry until we generate a valid +one. However, if we exceed this limit, something is likely wrong with the generator +instead, due to the unlikely high number of exceptions. +""" + +BENCHMARK_ITERATIONS = 5 +BENCHMARK_ROUNDS = 3 diff --git a/tests/benchmark/helpers.py b/tests/benchmark/helpers.py new file mode 100644 index 0000000..6e39074 --- /dev/null +++ b/tests/benchmark/helpers.py @@ -0,0 +1,306 @@ +import random +from datetime import datetime, timedelta + +from sqlalchemy.orm import Session + +from dibbler.models import Product, Transaction, TransactionType, User +from dibbler.queries import joint_buy_product +from tests.benchmark.benchmark_settings import TRANSACTION_GENERATOR_EXCEPTION_LIMIT + + +def insert_users_and_products( + sql_session: Session, + user_count: int = 10, + product_count: int = 10, +) -> tuple[list[User], list[Product]]: + users = [] + for i in range(user_count): + user = User(f"User{i + 1}") + sql_session.add(user) + users.append(user) + sql_session.commit() + + products = [] + for i in range(product_count): + barcode = str(1000000000000 + i) + product = Product(barcode, f"Product{i + 1}") + sql_session.add(product) + products.append(product) + sql_session.commit() + + return users, products + + +def generate_random_transactions( + sql_session: Session, + n: int, + seed: int = 42, + transaction_type_filter: list[TransactionType] | None = None, + distribution: dict[TransactionType, float] | None = None, + cache_every_n: int | None = None, +) -> list[Transaction]: + random.seed(seed) + + if transaction_type_filter is None: + transaction_type_filter = list(TransactionType) + + if TransactionType.JOINT_BUY_PRODUCT in transaction_type_filter: + transaction_type_filter.remove(TransactionType.JOINT_BUY_PRODUCT) + + # TODO: implement me + if TransactionType.THROW_PRODUCT in transaction_type_filter: + transaction_type_filter.remove(TransactionType.THROW_PRODUCT) + + if distribution is None: + distribution = {t: 1 / len(transaction_type_filter) for t in transaction_type_filter} + transaction_types = list(distribution.keys()) + weights = list(distribution.values()) + transactions: list[Transaction] = [] + last_time = datetime(2023, 1, 1, 0, 0, 0) + for _ in range(n): + transaction_type = random.choices(transaction_types, weights=weights, k=1)[0] + generator = RANDOM_GENERATORS[transaction_type] + transaction_or_transactions = generator(sql_session, last_time) + if isinstance(transaction_or_transactions, list): + transactions.extend(transaction_or_transactions) + last_time = max(t.time for t in transaction_or_transactions) + else: + transactions.append(transaction_or_transactions) + last_time = transaction_or_transactions.time + return transactions + + +def random_add_product_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + product = random.choice(sql_session.query(Product).all()) + product_count = random.randint(1, 10) + product_price = random.randint(15, 45) + amount = product_count * product_price + random.randint(-7, 0) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.add_product( + amount, + user.id, + product.id, + product_price, + product_count, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed", + ) + continue + return transaction + + +def random_adjust_balance_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + amount = random.randint(-50, 100) + if amount == 0: + amount = 1 + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.adjust_balance( + amount, + user.id, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed", + ) + continue + return transaction + + +def random_adjust_interest_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + amount = random.randint(100, 105) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.adjust_interest( + amount, + user.id, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed", + ) + continue + return transaction + + +def random_adjust_penalty_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + penalty_multiplier_percent = random.randint(100, 200) + penalty_threshold = random.randint(-150, -50) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.adjust_penalty( + penalty_multiplier_percent, + penalty_threshold, + user.id, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed", + ) + continue + return transaction + + +def random_adjust_stock_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + product = random.choice(sql_session.query(Product).all()) + stock_change = random.randint(-5, 6) + if stock_change == 0: + stock_change = 1 + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.adjust_stock( + user_id=user.id, + product_id=product.id, + product_count=stock_change, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed", + ) + continue + return transaction + + +def random_buy_product_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + product = random.choice(sql_session.query(Product).all()) + product_count = random.randint(1, 5) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.buy_product( + user_id=user.id, + product_id=product.id, + product_count=product_count, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed", + ) + continue + return transaction + + +def random_joint_transaction(sql_session: Session, last_time: datetime) -> list[Transaction]: + i = 0 + while True: + i += 1 + user_count = random.randint(2, 4) + users = random.sample(sql_session.query(User).all(), k=user_count) + product = random.choice(sql_session.query(Product).all()) + product_count = random.randint(1, 5) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + + try: + transactions = joint_buy_product( + sql_session, + product=product, + product_count=product_count, + instigator=users[0], + users=users, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed", + ) + continue + return transactions + + +def random_transfer_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + sender, receiver = random.sample(sql_session.query(User).all(), k=2) + amount = random.randint(1, 50) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.transfer( + amount, + sender.id, + receiver.id, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed", + ) + continue + return transaction + + +def random_throw_product_transaction(sql_session: Session, last_time: datetime) -> Transaction: + i = 0 + while True: + i += 1 + user = random.choice(sql_session.query(User).all()) + product = random.choice(sql_session.query(Product).all()) + product_count = random.randint(1, 5) + new_datetime = last_time + timedelta(minutes=random.randint(1, 60)) + try: + transaction = Transaction.throw_product( + user_id=user.id, + product_id=product.id, + product_count=product_count, + time=new_datetime, + ) + except Exception: + if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT: + raise RuntimeError( + "Too many failed attempts to create a valid transaction, consider changing the seed", + ) + continue + return transaction + + +RANDOM_GENERATORS = { + TransactionType.ADD_PRODUCT: random_add_product_transaction, + TransactionType.ADJUST_BALANCE: random_adjust_balance_transaction, + TransactionType.ADJUST_INTEREST: random_adjust_interest_transaction, + TransactionType.ADJUST_PENALTY: random_adjust_penalty_transaction, + TransactionType.ADJUST_STOCK: random_adjust_stock_transaction, + TransactionType.BUY_PRODUCT: random_buy_product_transaction, + TransactionType.JOINT: random_joint_transaction, + TransactionType.TRANSFER: random_transfer_transaction, + TransactionType.THROW_PRODUCT: random_throw_product_transaction, +} diff --git a/tests/benchmark/test_benchmark_product_owners.py b/tests/benchmark/test_benchmark_product_owners.py new file mode 100644 index 0000000..c90787f --- /dev/null +++ b/tests/benchmark/test_benchmark_product_owners.py @@ -0,0 +1,54 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import Product, TransactionType +from dibbler.queries import product_owners +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + + +@pytest.mark.benchmark(group="product_owners") +@pytest.mark.parametrize( + "transaction_count", + [ + 100, + 500, + 1000, + 2000, + 5000, + 10000, + ], +) +def test_benchmark_product_owners( + benchmark, + sql_session: Session, + transaction_count: int, +) -> None: + _users, products = insert_users_and_products(sql_session) + + transactions = generate_random_transactions( + sql_session, + transaction_count, + transaction_type_filter=[ + TransactionType.ADD_PRODUCT, + TransactionType.ADJUST_STOCK, + TransactionType.BUY_PRODUCT, + TransactionType.JOINT, + TransactionType.THROW_PRODUCT, + ], + ) + + sql_session.add_all(transactions) + sql_session.commit() + + benchmark.pedantic( + query_all_product_owners, + args=(sql_session, products), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_all_product_owners(sql_session: Session, products: list[Product]) -> None: + for product in products: + product_owners(sql_session, product, use_cache=False) diff --git a/tests/benchmark/test_benchmark_product_price.py b/tests/benchmark/test_benchmark_product_price.py new file mode 100644 index 0000000..f8c53be --- /dev/null +++ b/tests/benchmark/test_benchmark_product_price.py @@ -0,0 +1,92 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import Product, TransactionType +from dibbler.queries import product_price, update_cache +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + +# @pytest.mark.benchmark(group="product_price") +# @pytest.mark.parametrize( +# "transaction_count", +# [ +# 100, +# 500, +# 1000, +# 2000, +# 5000, +# 10000, +# ], +# ) +# def test_benchmark_product_price(benchmark, sql_session: Session, transaction_count): +# _users, products = insert_users_and_products(sql_session) + +# transactions = generate_random_transactions( +# sql_session, +# transaction_count, +# transaction_type_filter=[ +# TransactionType.ADD_PRODUCT, +# TransactionType.ADJUST_STOCK, +# TransactionType.BUY_PRODUCT, +# TransactionType.JOINT, +# TransactionType.THROW_PRODUCT, +# ], +# ) + +# sql_session.add_all(transactions) +# sql_session.commit() + +# benchmark.pedantic( +# query_all_products_price, +# args=(sql_session, products), +# iterations=BENCHMARK_ITERATIONS, +# rounds=BENCHMARK_ROUNDS, +# ) + + +@pytest.mark.benchmark(group="product_price") +@pytest.mark.parametrize( + "transaction_count", + [ + 1000, + 2000, + 5000, + 10000, + ], +) +def test_benchmark_product_price_cache_every_500( + benchmark, + sql_session: Session, + transaction_count: int, +) -> None: + users, _products = insert_users_and_products(sql_session) + + transactions = generate_random_transactions( + sql_session, + transaction_count, + transaction_type_filter=[ + TransactionType.ADD_PRODUCT, + TransactionType.ADJUST_STOCK, + TransactionType.BUY_PRODUCT, + TransactionType.JOINT, + TransactionType.THROW_PRODUCT, + ], + ) + + for i in range(0, len(transactions), 500): + update_cache(sql_session) + + sql_session.add_all(transactions[i : i + 500]) + sql_session.commit() + + benchmark.pedantic( + query_all_products_price, + args=(sql_session, users), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_all_products_price(sql_session: Session, products: list[Product]) -> None: + for product in products: + product_price(sql_session, product, use_cache=False) diff --git a/tests/benchmark/test_benchmark_product_stock.py b/tests/benchmark/test_benchmark_product_stock.py new file mode 100644 index 0000000..3b0c4b8 --- /dev/null +++ b/tests/benchmark/test_benchmark_product_stock.py @@ -0,0 +1,54 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import Product, TransactionType +from dibbler.queries import product_stock +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + + +@pytest.mark.benchmark(group="product_stock") +@pytest.mark.parametrize( + "transaction_count", + [ + 100, + 500, + 1000, + 2000, + 5000, + 10000, + ], +) +def test_benchmark_product_stock( + benchmark, + sql_session: Session, + transaction_count: int, +) -> None: + _users, products = insert_users_and_products(sql_session) + + transactions = generate_random_transactions( + sql_session, + transaction_count, + transaction_type_filter=[ + TransactionType.ADD_PRODUCT, + TransactionType.ADJUST_STOCK, + TransactionType.BUY_PRODUCT, + TransactionType.JOINT, + TransactionType.THROW_PRODUCT, + ], + ) + + sql_session.add_all(transactions) + sql_session.commit() + + benchmark.pedantic( + query_all_products_stock, + args=(sql_session, products), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_all_products_stock(sql_session: Session, products: list[Product]) -> None: + for product in products: + product_stock(sql_session, product, use_cache=False) diff --git a/tests/benchmark/test_benchmark_transaction_log.py b/tests/benchmark/test_benchmark_transaction_log.py new file mode 100644 index 0000000..0dfa45d --- /dev/null +++ b/tests/benchmark/test_benchmark_transaction_log.py @@ -0,0 +1,54 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import Product, User +from dibbler.queries import transaction_log +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + + +@pytest.mark.benchmark(group="transaction_log") +@pytest.mark.parametrize( + "transaction_count", + [ + 100, + 500, + 1000, + 2000, + 5000, + 10000, + ], +) +def test_benchmark_transaction_log( + benchmark, + sql_session: Session, + transaction_count: int, +) -> None: + users, products = insert_users_and_products(sql_session) + + transactions = generate_random_transactions( + sql_session, + transaction_count, + ) + + sql_session.add_all(transactions) + sql_session.commit() + + benchmark.pedantic( + query_transaction_log, + args=( + sql_session, + products, + users, + ), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_transaction_log(sql_session: Session, products: list[Product], users: list[User]) -> None: + for user in users: + transaction_log(sql_session, user=user) + + for product in products: + transaction_log(sql_session, product=product) diff --git a/tests/benchmark/test_benchmark_user_balance.py b/tests/benchmark/test_benchmark_user_balance.py new file mode 100644 index 0000000..f7d0d3a --- /dev/null +++ b/tests/benchmark/test_benchmark_user_balance.py @@ -0,0 +1,81 @@ +import pytest +from sqlalchemy.orm import Session + +from dibbler.models import User +from dibbler.queries import update_cache, user_balance +from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS +from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products + + +@pytest.mark.benchmark(group="user_balance") +@pytest.mark.parametrize( + "transaction_count", + [ + 100, + 500, + 1000, + 1500, + 2000, + ], +) +def test_benchmark_user_balance( + benchmark, + sql_session: Session, + transaction_count: int, +) -> None: + users, _products = insert_users_and_products(sql_session) + + transactions = generate_random_transactions( + sql_session, + transaction_count, + ) + + sql_session.add_all(transactions) + sql_session.commit() + + benchmark.pedantic( + query_all_users_balance, + args=(sql_session, users), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +@pytest.mark.benchmark(group="user_balance") +@pytest.mark.parametrize( + "transaction_count", + [ + 1000, + 1500, + 2000, + ], +) +def test_benchmark_user_balance_cache_every_500( + benchmark, + sql_session: Session, + transaction_count: int, +) -> None: + users, _products = insert_users_and_products(sql_session) + + transactions = generate_random_transactions( + sql_session, + transaction_count, + ) + + for i in range(0, len(transactions), 500): + update_cache(sql_session) + + sql_session.add_all(transactions[i : i + 500]) + sql_session.commit() + + benchmark.pedantic( + query_all_users_balance, + args=(sql_session, users), + iterations=BENCHMARK_ITERATIONS, + rounds=BENCHMARK_ROUNDS, + ) + + +def query_all_users_balance(sql_session: Session, users: list[User]) -> None: + for user in users: + user_balance(sql_session, user, use_cache=False) diff --git a/uv.lock b/uv.lock index 299b1f8..87707b6 100644 --- a/uv.lock +++ b/uv.lock @@ -140,6 +140,7 @@ dependencies = [ test = [ { name = "coverage-badge" }, { name = "pytest" }, + { name = "pytest-benchmark", extra = ["histogram"] }, { name = "pytest-cov" }, { name = "pytest-html" }, { name = "sqlparse" }, @@ -155,6 +156,7 @@ requires-dist = [ test = [ { name = "coverage-badge", specifier = ">=1.1.2" }, { name = "pytest" }, + { name = "pytest-benchmark", extras = ["histogram"], specifier = ">=5.2.3" }, { name = "pytest-cov" }, { name = "pytest-html", specifier = ">=4.1.1" }, { name = "sqlparse", specifier = ">=0.5.4" }, @@ -212,6 +214,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -377,6 +391,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, ] +[[package]] +name = "py-cpuinfo" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" }, +] + +[[package]] +name = "pygal" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b6/04176faeb312c84d7f9bc1a810f96ee38d15597e226bb9bda59f3a5cb122/pygal-3.1.0.tar.gz", hash = "sha256:fbdee7351a7423e7907fb8a9c3b77305f6b5678cb2e6fd0db36a8825e42955ec", size = 81006, upload-time = "2025-12-09T10:29:19.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/4c/2862dd25352fe4b22ec7760d4fa12cb692587cd7ec3e378cbf644fc0d2a8/pygal-3.1.0-py3-none-any.whl", hash = "sha256:4e923490f3490c90c481f4535fa3adcda20ff374257ab9d8ae897f91b632c0bb", size = 130171, upload-time = "2025-12-09T10:29:16.721Z" }, +] + +[[package]] +name = "pygaljs" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/19/3a53f34232a9e6ddad665e71c83693c5db9a31f71785105905c5bc9fbbba/pygaljs-1.0.2.tar.gz", hash = "sha256:0b71ee32495dcba5fbb4a0476ddbba07658ad65f5675e4ad409baf154dec5111", size = 89711, upload-time = "2020-04-03T07:51:44.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/6f/07dab31ca496feda35cf3455b9e9380c43b5c685bb54ad890831c790da38/pygaljs-1.0.2-py2.py3-none-any.whl", hash = "sha256:d75e18cb21cc2cda40c45c3ee690771e5e3d4652bf57206f20137cf475c0dbe8", size = 91111, upload-time = "2020-04-03T07:51:42.658Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -402,6 +446,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-benchmark" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py-cpuinfo" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" }, +] + +[package.optional-dependencies] +histogram = [ + { name = "pygal" }, + { name = "pygaljs" }, + { name = "setuptools" }, +] + [[package]] name = "pytest-cov" version = "7.0.0" @@ -571,3 +635,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]