From f334415355ea5e75990f82ded165344067d562c7 Mon Sep 17 00:00:00 2001 From: Somebody Else Date: Tue, 19 May 2026 09:59:20 +0300 Subject: [PATCH 1/3] A lot of changes --- .github/workflows/ci.yml | 81 ++ README.md | 173 ++++- .../20260517_0005_auth_users_configs_rbac.py | 2 +- .../20260517_0006_normalize_projects_table.py | 2 +- .../20260517_0007_normalize_roles_table.py | 2 +- app_core/lifecycle.py | 77 ++ app_core/logging_config.py | 80 ++ auth/auth_service.py | 14 +- cache/shared_cache.py | 7 +- cache/ttl_cache.py | 5 +- crud/__init__.py | 42 +- crud/common.py | 4 +- crud/prompt.py | 26 +- crud/user.py | 4 +- database.py | 40 +- diagnostic.py | 2 +- loadtests/benchmark_rps.py | 34 +- loadtests/chart_db_mode_40_p95.png | Bin 0 -> 37123 bytes loadtests/chart_db_mode_40_rps.png | Bin 0 -> 34301 bytes loadtests/chart_db_mode_cold_10_p95.png | Bin 0 -> 30316 bytes loadtests/chart_db_mode_dashboard.png | Bin 0 -> 164606 bytes .../postgres_async/benchmark_manifest.json | 118 +++ .../postgres_async/benchmark_summary.md | 16 + .../postgres_async/cache/u10_exceptions.csv | 1 + .../postgres_async/cache/u10_failures.csv | 1 + .../postgres_async/cache/u10_stats.csv | 9 + .../cache/u10_stats_history.csv | 12 + .../postgres_async/cache/u20_exceptions.csv | 1 + .../postgres_async/cache/u20_failures.csv | 1 + .../postgres_async/cache/u20_stats.csv | 9 + .../cache/u20_stats_history.csv | 12 + .../postgres_async/cache/u40_exceptions.csv | 1 + .../postgres_async/cache/u40_failures.csv | 1 + .../postgres_async/cache/u40_stats.csv | 9 + .../cache/u40_stats_history.csv | 12 + .../postgres_async/mixed/u10_exceptions.csv | 1 + .../postgres_async/mixed/u10_failures.csv | 1 + .../postgres_async/mixed/u10_stats.csv | 9 + .../mixed/u10_stats_history.csv | 12 + .../postgres_async/mixed/u20_exceptions.csv | 1 + .../postgres_async/mixed/u20_failures.csv | 1 + .../postgres_async/mixed/u20_stats.csv | 9 + .../mixed/u20_stats_history.csv | 12 + .../postgres_async/mixed/u40_exceptions.csv | 1 + .../postgres_async/mixed/u40_failures.csv | 1 + .../postgres_async/mixed/u40_stats.csv | 9 + .../mixed/u40_stats_history.csv | 12 + .../optimize_cold/u10_exceptions.csv | 1 + .../optimize_cold/u10_failures.csv | 1 + .../optimize_cold/u10_stats.csv | 5 + .../optimize_cold/u10_stats_history.csv | 12 + .../optimize_cold/u20_exceptions.csv | 1 + .../optimize_cold/u20_failures.csv | 1 + .../optimize_cold/u20_stats.csv | 4 + .../optimize_cold/u20_stats_history.csv | 12 + .../optimize_cold/u40_exceptions.csv | 1 + .../optimize_cold/u40_failures.csv | 1 + .../optimize_cold/u40_stats.csv | 4 + .../optimize_cold/u40_stats_history.csv | 12 + .../optimize_hot/u10_exceptions.csv | 1 + .../optimize_hot/u10_failures.csv | 1 + .../postgres_async/optimize_hot/u10_stats.csv | 5 + .../optimize_hot/u10_stats_history.csv | 12 + .../optimize_hot/u20_exceptions.csv | 1 + .../optimize_hot/u20_failures.csv | 1 + .../postgres_async/optimize_hot/u20_stats.csv | 5 + .../optimize_hot/u20_stats_history.csv | 12 + .../optimize_hot/u40_exceptions.csv | 1 + .../optimize_hot/u40_failures.csv | 1 + .../postgres_async/optimize_hot/u40_stats.csv | 5 + .../optimize_hot/u40_stats_history.csv | 12 + .../postgres_sync/benchmark_manifest.json | 118 +++ .../postgres_sync/benchmark_summary.md | 16 + .../postgres_sync/cache/u10_exceptions.csv | 1 + .../postgres_sync/cache/u10_failures.csv | 1 + .../postgres_sync/cache/u10_stats.csv | 9 + .../postgres_sync/cache/u10_stats_history.csv | 12 + .../postgres_sync/cache/u20_exceptions.csv | 1 + .../postgres_sync/cache/u20_failures.csv | 1 + .../postgres_sync/cache/u20_stats.csv | 9 + .../postgres_sync/cache/u20_stats_history.csv | 12 + .../postgres_sync/cache/u40_exceptions.csv | 1 + .../postgres_sync/cache/u40_failures.csv | 1 + .../postgres_sync/cache/u40_stats.csv | 9 + .../postgres_sync/cache/u40_stats_history.csv | 12 + .../postgres_sync/mixed/u10_exceptions.csv | 1 + .../postgres_sync/mixed/u10_failures.csv | 1 + .../postgres_sync/mixed/u10_stats.csv | 9 + .../postgres_sync/mixed/u10_stats_history.csv | 12 + .../postgres_sync/mixed/u20_exceptions.csv | 1 + .../postgres_sync/mixed/u20_failures.csv | 1 + .../postgres_sync/mixed/u20_stats.csv | 9 + .../postgres_sync/mixed/u20_stats_history.csv | 12 + .../postgres_sync/mixed/u40_exceptions.csv | 1 + .../postgres_sync/mixed/u40_failures.csv | 1 + .../postgres_sync/mixed/u40_stats.csv | 9 + .../postgres_sync/mixed/u40_stats_history.csv | 12 + .../optimize_cold/u10_exceptions.csv | 1 + .../optimize_cold/u10_failures.csv | 1 + .../postgres_sync/optimize_cold/u10_stats.csv | 5 + .../optimize_cold/u10_stats_history.csv | 12 + .../optimize_cold/u20_exceptions.csv | 1 + .../optimize_cold/u20_failures.csv | 1 + .../postgres_sync/optimize_cold/u20_stats.csv | 4 + .../optimize_cold/u20_stats_history.csv | 12 + .../optimize_cold/u40_exceptions.csv | 1 + .../optimize_cold/u40_failures.csv | 1 + .../postgres_sync/optimize_cold/u40_stats.csv | 4 + .../optimize_cold/u40_stats_history.csv | 12 + .../optimize_hot/u10_exceptions.csv | 1 + .../optimize_hot/u10_failures.csv | 1 + .../postgres_sync/optimize_hot/u10_stats.csv | 5 + .../optimize_hot/u10_stats_history.csv | 12 + .../optimize_hot/u20_exceptions.csv | 1 + .../optimize_hot/u20_failures.csv | 1 + .../postgres_sync/optimize_hot/u20_stats.csv | 5 + .../optimize_hot/u20_stats_history.csv | 12 + .../optimize_hot/u40_exceptions.csv | 1 + .../optimize_hot/u40_failures.csv | 1 + .../postgres_sync/optimize_hot/u40_stats.csv | 5 + .../optimize_hot/u40_stats_history.csv | 12 + .../sqlite_sync/benchmark_manifest.json | 118 +++ .../sqlite_sync/benchmark_summary.md | 16 + .../sqlite_sync/cache/u10_exceptions.csv | 1 + .../sqlite_sync/cache/u10_failures.csv | 1 + .../sqlite_sync/cache/u10_stats.csv | 9 + .../sqlite_sync/cache/u10_stats_history.csv | 12 + .../sqlite_sync/cache/u20_exceptions.csv | 1 + .../sqlite_sync/cache/u20_failures.csv | 1 + .../sqlite_sync/cache/u20_stats.csv | 9 + .../sqlite_sync/cache/u20_stats_history.csv | 12 + .../sqlite_sync/cache/u40_exceptions.csv | 1 + .../sqlite_sync/cache/u40_failures.csv | 1 + .../sqlite_sync/cache/u40_stats.csv | 9 + .../sqlite_sync/cache/u40_stats_history.csv | 12 + .../sqlite_sync/mixed/u10_exceptions.csv | 1 + .../sqlite_sync/mixed/u10_failures.csv | 1 + .../sqlite_sync/mixed/u10_stats.csv | 9 + .../sqlite_sync/mixed/u10_stats_history.csv | 12 + .../sqlite_sync/mixed/u20_exceptions.csv | 1 + .../sqlite_sync/mixed/u20_failures.csv | 1 + .../sqlite_sync/mixed/u20_stats.csv | 9 + .../sqlite_sync/mixed/u20_stats_history.csv | 12 + .../sqlite_sync/mixed/u40_exceptions.csv | 1 + .../sqlite_sync/mixed/u40_failures.csv | 1 + .../sqlite_sync/mixed/u40_stats.csv | 9 + .../sqlite_sync/mixed/u40_stats_history.csv | 12 + .../optimize_cold/u10_exceptions.csv | 1 + .../optimize_cold/u10_failures.csv | 1 + .../sqlite_sync/optimize_cold/u10_stats.csv | 5 + .../optimize_cold/u10_stats_history.csv | 12 + .../optimize_cold/u20_exceptions.csv | 1 + .../optimize_cold/u20_failures.csv | 1 + .../sqlite_sync/optimize_cold/u20_stats.csv | 2 + .../optimize_cold/u20_stats_history.csv | 12 + .../optimize_cold/u40_exceptions.csv | 1 + .../optimize_cold/u40_failures.csv | 1 + .../sqlite_sync/optimize_cold/u40_stats.csv | 2 + .../optimize_cold/u40_stats_history.csv | 12 + .../optimize_hot/u10_exceptions.csv | 1 + .../sqlite_sync/optimize_hot/u10_failures.csv | 1 + .../sqlite_sync/optimize_hot/u10_stats.csv | 5 + .../optimize_hot/u10_stats_history.csv | 12 + .../optimize_hot/u20_exceptions.csv | 1 + .../sqlite_sync/optimize_hot/u20_failures.csv | 1 + .../sqlite_sync/optimize_hot/u20_stats.csv | 5 + .../optimize_hot/u20_stats_history.csv | 12 + .../optimize_hot/u40_exceptions.csv | 1 + .../sqlite_sync/optimize_hot/u40_failures.csv | 1 + .../sqlite_sync/optimize_hot/u40_stats.csv | 5 + .../optimize_hot/u40_stats_history.csv | 12 + main.py | 734 +++--------------- middleware/__init__.py | 4 + middleware/exception_logging.py | 26 + middleware/request_logging.py | 30 + models/__init__.py | 6 +- models/models.py | 24 +- optimizer/__init__.py | 8 +- optimizer/base.py | 11 +- optimizer/leo_backend.py | 28 +- optimizer/service.py | 50 +- pyproject.toml | 60 +- requirements.txt | 1 + routes/__init__.py | 69 ++ routes/app_version.py | 4 + routes/auth_bootstrap_admin.py | 21 + routes/auth_change_password.py | 14 + routes/auth_login.py | 12 + routes/auth_me.py | 7 + routes/auth_refresh.py | 7 + routes/auth_status.py | 8 + routes/optimize_config_get.py | 14 + routes/optimize_config_update.py | 15 + routes/optimize_prompt.py | 28 + routes/optimize_provider_models.py | 26 + routes/projects_create.py | 14 + routes/projects_delete.py | 12 + routes/projects_get.py | 13 + routes/projects_list.py | 8 + routes/projects_update.py | 17 + routes/prompts_create.py | 42 + routes/prompts_delete.py | 21 + routes/prompts_get.py | 27 + routes/prompts_list.py | 66 ++ routes/prompts_search.py | 39 + routes/prompts_update.py | 43 + routes/prompts_update_tags.py | 22 + routes/prompts_version_get.py | 32 + routes/prompts_versions_list.py | 29 + routes/roles_list.py | 7 + routes/serve_ui.py | 5 + routes/shared.py | 89 +++ routes/users_create.py | 17 + routes/users_delete.py | 15 + routes/users_get.py | 13 + routes/users_list.py | 8 + routes/users_update.py | 27 + routes/users_update_projects.py | 14 + ruff.toml | 31 + schemas/schemas.py | 1 - security.py | 6 + test_imports.py | 3 +- tests/conftest.py | 24 +- tests/e2e/test_e2e_full_flow.py | 5 +- tests/integration/test_auth_rbac_endpoints.py | 11 +- tests/integration/test_race_conditions.py | 25 +- tests/unit/test_unit_optimizer_and_crud.py | 22 +- 227 files changed, 2853 insertions(+), 805 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 app_core/lifecycle.py create mode 100644 app_core/logging_config.py create mode 100644 loadtests/chart_db_mode_40_p95.png create mode 100644 loadtests/chart_db_mode_40_rps.png create mode 100644 loadtests/chart_db_mode_cold_10_p95.png create mode 100644 loadtests/chart_db_mode_dashboard.png create mode 100644 loadtests/results/db_compare/postgres_async/benchmark_manifest.json create mode 100644 loadtests/results/db_compare/postgres_async/benchmark_summary.md create mode 100644 loadtests/results/db_compare/postgres_async/cache/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u10_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u10_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u20_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u20_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u40_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u40_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/cache/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u10_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u10_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u20_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u20_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u40_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u40_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/mixed/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u10_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u10_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u20_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u20_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u40_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u40_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_cold/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u10_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u10_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u20_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u20_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u40_failures.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u40_stats.csv create mode 100644 loadtests/results/db_compare/postgres_async/optimize_hot/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/benchmark_manifest.json create mode 100644 loadtests/results/db_compare/postgres_sync/benchmark_summary.md create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u10_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u10_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u20_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u20_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u40_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u40_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/cache/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u10_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u10_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u20_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u20_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u40_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u40_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/mixed/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u10_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u10_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u20_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u20_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u40_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u40_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_cold/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u10_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u10_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u20_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u20_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u40_failures.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u40_stats.csv create mode 100644 loadtests/results/db_compare/postgres_sync/optimize_hot/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/benchmark_manifest.json create mode 100644 loadtests/results/db_compare/sqlite_sync/benchmark_summary.md create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u10_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u10_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u20_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u20_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u40_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u40_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/cache/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u10_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u10_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u20_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u20_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u40_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u40_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/mixed/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_stats_history.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_exceptions.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_failures.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_stats.csv create mode 100644 loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_stats_history.csv create mode 100644 middleware/__init__.py create mode 100644 middleware/exception_logging.py create mode 100644 middleware/request_logging.py create mode 100644 routes/__init__.py create mode 100644 routes/app_version.py create mode 100644 routes/auth_bootstrap_admin.py create mode 100644 routes/auth_change_password.py create mode 100644 routes/auth_login.py create mode 100644 routes/auth_me.py create mode 100644 routes/auth_refresh.py create mode 100644 routes/auth_status.py create mode 100644 routes/optimize_config_get.py create mode 100644 routes/optimize_config_update.py create mode 100644 routes/optimize_prompt.py create mode 100644 routes/optimize_provider_models.py create mode 100644 routes/projects_create.py create mode 100644 routes/projects_delete.py create mode 100644 routes/projects_get.py create mode 100644 routes/projects_list.py create mode 100644 routes/projects_update.py create mode 100644 routes/prompts_create.py create mode 100644 routes/prompts_delete.py create mode 100644 routes/prompts_get.py create mode 100644 routes/prompts_list.py create mode 100644 routes/prompts_search.py create mode 100644 routes/prompts_update.py create mode 100644 routes/prompts_update_tags.py create mode 100644 routes/prompts_version_get.py create mode 100644 routes/prompts_versions_list.py create mode 100644 routes/roles_list.py create mode 100644 routes/serve_ui.py create mode 100644 routes/shared.py create mode 100644 routes/users_create.py create mode 100644 routes/users_delete.py create mode 100644 routes/users_get.py create mode 100644 routes/users_list.py create mode 100644 routes/users_update.py create mode 100644 routes/users_update_projects.py create mode 100644 ruff.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3dba581 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + ruff: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install lint dependencies + run: | + python -m pip install --upgrade pip + python -m pip install ruff + + - name: Run Ruff + run: python -m ruff check . + + mypy: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install mypy dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install mypy sqlalchemy[mypy] types-requests httpx pytest + + - name: Run mypy + run: python -m mypy . + + tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + python -m pip install pytest pytest-cov httpx sqlalchemy[mypy] types-requests + + - name: Run tests with coverage + run: python -m pytest tests --cov --cov-report=xml --cov-report=term-missing -q + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/README.md b/README.md index 24fd904..ff6ea97 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Prompt Man +[![CI](https://github.com/VeryComplexAndLongName/PromptMan/actions/workflows/ci.yml/badge.svg)](https://github.com/VeryComplexAndLongName/PromptMan/actions/workflows/ci.yml) +[![Docker](https://github.com/VeryComplexAndLongName/PromptMan/actions/workflows/docker.yml/badge.svg)](https://github.com/VeryComplexAndLongName/PromptMan/actions/workflows/docker.yml) +[![Coverage](https://codecov.io/gh/VeryComplexAndLongName/PromptMan/graph/badge.svg)](https://codecov.io/gh/VeryComplexAndLongName/PromptMan) + Prompt Man: FastAPI + Vue app for storing, versioning, and optimizing prompts. Prompt Man is **REST API-first**: the primary product surface is the HTTP API, and the application architecture is optimized around API workflows. @@ -97,6 +101,13 @@ uv sync --extra dev alembic upgrade head ``` +## CI And Coverage + +- GitHub Actions workflow `CI` runs `ruff`, `mypy`, and the test suite on every push and pull request to `main`. +- Coverage is uploaded to Codecov from the same workflow, which enables the coverage badge above. +- For a public repository, the Codecov badge usually works after installing the Codecov GitHub App. +- For a private repository, add `CODECOV_TOKEN` to repository secrets before the upload step can succeed. + ### Using pip ```powershell @@ -471,17 +482,11 @@ PostgreSQL is a network database that all instances connect to simultaneously. ### Switching From SQLite To PostgreSQL -**1. Install the driver** (add to `requirements.txt`): - -``` -psycopg2-binary -``` +**1. Use the built-in PostgreSQL driver already included in the project dependencies:** -Or for async support: +Prompt Man already includes `psycopg2-binary` for PostgreSQL. -``` -asyncpg -``` +No code changes are required to switch modes in this repository. **2. Create a PostgreSQL database and user:** @@ -497,6 +502,11 @@ GRANT ALL PRIVILEGES ON DATABASE promptman TO promptman_user; DATABASE_URL=postgresql://promptman_user:your-strong-password@db-host:5432/promptman ``` +Notes: + +- The application runtime is synchronous for both SQLite and PostgreSQL. +- Startup migrations run automatically on every boot. + For Docker: ```powershell @@ -516,6 +526,27 @@ The application creates and migrates the entire database schema automatically on [startup] Application is ready to serve requests ``` +### Switching Back From PostgreSQL To SQLite + +Switching back is also only an environment change. + +PowerShell: + +```powershell +$env:DATABASE_URL = "sqlite:///./prompts.db" +uvicorn main:app --host 127.0.0.1 --port 8000 +``` + +Docker: + +```powershell +docker run --name prompt-man --restart unless-stopped -p 8000:8000 ` + -e DATABASE_URL=sqlite:////data/prompts.db ` + -e PROMPTMAN_KEY="your-long-stable-secret" ` + -v promptman-data:/data ` + verycomplexandlongname/prompt-man:latest +``` + ### Database URL Reference | Database | Example `DATABASE_URL` | @@ -559,6 +590,24 @@ Load-test scenarios run at 10, 20, and 40 concurrent users. Results confirm that python loadtests/benchmark_rps.py --host http://127.0.0.1:8000 --duration 15s --users 10 20 40 --spawn-rate 10 --scenarios mixed cache optimize_hot optimize_cold --clean ``` +### Switch database mode for benchmarks + +Switching between **SQLite sync** and **PostgreSQL sync** is just a `DATABASE_URL` change. + +SQLite sync: + +```powershell +$env:DATABASE_URL = "sqlite:///./prompts.db" +uvicorn main:app --host 127.0.0.1 --port 8000 +``` + +PostgreSQL sync: + +```powershell +$env:DATABASE_URL = "postgresql://postgres:your-password@localhost:5432/promptman" +uvicorn main:app --host 127.0.0.1 --port 8000 +``` + ### Build charts ```powershell @@ -570,32 +619,83 @@ When `loadtests/results/benchmark_manifest.json` exists, chart generation uses t ### Latest benchmark snapshot -Local benchmark snapshot (`15s`, `10/20/40` users, shared auth token, default thresholds `failure<=1%`, `p95<=500ms`): +Current local SQLite sync snapshot (`15s`, `10/20/40` users, shared auth token, default thresholds `failure<=1%`, `p95<=500ms`): | Scenario | Users | RPS | P95 (ms) | Avg (ms) | Failure % | Pass | | --- | ---: | ---: | ---: | ---: | ---: | :---: | -| mixed | 10 | 24.17 | 1000.00 | 158.50 | 0.00 | no | -| mixed | 20 | 13.38 | 4600.00 | 1075.67 | 0.00 | no | -| mixed | 40 | 10.71 | 1000.00 | 104.25 | 0.00 | no | -| cache | 10 | 97.51 | 31.00 | 13.23 | 0.00 | yes | -| cache | 20 | 162.20 | 58.00 | 27.94 | 0.00 | yes | -| cache | 40 | 174.15 | 160.00 | 104.27 | 0.00 | yes | -| optimize_hot | 10 | 19.75 | 37.00 | 13.40 | 0.00 | yes | -| optimize_hot | 20 | 37.87 | 35.00 | 15.38 | 0.00 | yes | -| optimize_hot | 40 | 68.47 | 66.00 | 25.81 | 0.00 | yes | -| optimize_cold | 10 | 0.18 | 11000.00 | 6947.02 | 0.00 | no | +| mixed | 10 | 34.28 | 140.00 | 27.07 | 0.00 | yes | +| mixed | 20 | 43.01 | 860.00 | 151.87 | 0.00 | no | +| mixed | 40 | 42.82 | 810.00 | 155.43 | 0.00 | no | +| cache | 10 | 102.26 | 19.00 | 9.18 | 0.00 | yes | +| cache | 20 | 189.02 | 30.00 | 13.68 | 0.00 | yes | +| cache | 40 | 255.70 | 80.00 | 45.51 | 0.00 | yes | +| optimize_hot | 10 | 19.50 | 19.00 | 9.41 | 0.00 | yes | +| optimize_hot | 20 | 38.48 | 35.00 | 11.60 | 0.00 | yes | +| optimize_hot | 40 | 72.01 | 39.00 | 13.85 | 0.00 | yes | +| optimize_cold | 10 | 0.16 | 12000.00 | 9396.95 | 0.00 | no | | optimize_cold | 20 | 0.00 | inf | inf | 100.00 | no | | optimize_cold | 40 | 0.00 | inf | inf | 100.00 | no | Observed takeaway: - **Zero failures** across all cache and hot-optimization scenarios at every concurrency level — the application handles concurrent access without errors or data corruption. -- **Cache-heavy traffic scales linearly**: from `97.51 RPS` at 10 users to `174.15 RPS` at 40 users, all with zero failures. This is the dominant usage pattern for teams repeatedly reading shared prompts. -- **Hot optimization sustains high throughput under concurrency**: `19.75 → 37.87 → 68.47 RPS` as users grow from 10 to 40 — near-linear scaling with sub-70 ms p95. +- **Cache-heavy traffic scales strongly**: from `102.26 RPS` at `10` users to `255.70 RPS` at `40` users, all with zero failures. This is the dominant usage pattern for teams repeatedly reading shared prompts. +- **Hot optimization sustains high throughput under concurrency**: `19.50 → 38.48 → 72.01 RPS` as users grow from `10` to `40`, all with zero failures and sub-`40 ms` p95 even at the highest measured concurrency. - Mixed CRUD/search traffic is dominated by LLM-bound optimize calls, so it misses the default p95 threshold on this local single-host setup. This reflects the LLM provider latency, not a concurrency issue in the application itself. - Cache reuse delivers approximately **110× throughput gain** and **297× p95 reduction** compared to cold optimization on the same host — making repeated prompt optimization viable under team load. - At 20 and 40 users, cold optimization (no cache, real LLM call) did not complete requests inside the 15-second window — expected behavior given local LLM latency, not an application defect. +### DB Mode Comparison + +The application is currently supported in two runtime configurations: + +- SQLite sync +- PostgreSQL sync + +Historical note: before async runtime support was removed, the application was also benchmarked in a PostgreSQL async prototype. Those measurements are kept below for comparison, but that mode is no longer part of the supported application runtime because it did not provide practical gains on this codebase. + +Switching between SQLite sync and PostgreSQL sync was verified by changing only `DATABASE_URL`. + +### Concurrency smoke comparison + +Live HTTP concurrency smoke (`12` parallel prompt creates + `12` parallel list requests, one shared project, one shared tag): + +| Mode | Create OK | List OK | Final prompt count | +| --- | ---: | ---: | ---: | +| SQLite sync | 12/12 | 12/12 | 12 | +| PostgreSQL sync | 12/12 | 12/12 | 12 | +| PostgreSQL async (historical) | 12/12 | 12/12 | 12 | + +### 40-user benchmark comparison + +Measured at `40` concurrent users on the same machine: + +| Mode | Mixed RPS | Mixed P95 (ms) | Cache RPS | Cache P95 (ms) | Optimize Hot RPS | Optimize Hot P95 (ms) | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| SQLite sync | 42.82 | 810.00 | 255.70 | 80.00 | 72.01 | 39.00 | +| PostgreSQL sync | 60.62 | 690.00 | 243.62 | 87.00 | 70.69 | 43.00 | +| PostgreSQL async (historical) | 36.22 | 3100.00 | 179.72 | 200.00 | 68.62 | 100.00 | + +### Cold optimize comparison at 10 users + +`optimize_cold` remains dominated by LLM latency, so `10` users is the only meaningful finite comparison point: + +| Mode | RPS | P95 (ms) | Avg (ms) | Failure % | +| --- | ---: | ---: | ---: | ---: | +| SQLite sync | 0.16 | 12000.00 | 9396.95 | 0.00 | +| PostgreSQL sync | 0.39 | 13000.00 | 7485.71 | 0.00 | +| PostgreSQL async (historical) | 0.18 | 11000.00 | 7144.59 | 0.00 | + +Observed takeaway from the DB-mode comparison: + +- **PostgreSQL sync is the best balanced measured mode** on this codebase: it improves mixed-workload throughput and mixed-workload P95 relative to SQLite sync, while keeping cache and hot-optimize performance close. +- **SQLite sync is still very competitive for read-heavy local workloads** and reached the highest measured cache-only throughput on this machine (`255.70 RPS` at `40` users). +- **The historical PostgreSQL async prototype did not deliver practical benefits**: mixed throughput was lower than both sync modes, mixed P95 was materially worse, and hot/cache paths were not improved enough to justify the extra runtime complexity. +- **PostgreSQL is the recommended production mode** for this codebase: it remains operationally simple to enable and was the strongest overall measured option under mixed concurrent load. +- **Database switching is operationally simple**: SQLite sync ↔ PostgreSQL sync is just a `DATABASE_URL` change. +- **Concurrency correctness was the same across all measured modes in live smoke testing**: every measured mode completed `12/12` concurrent creates and `12/12` concurrent list requests with the expected final prompt count. +- **Async runtime support was removed from the application after these measurements**: the benchmark data is preserved here as the rationale for keeping only the simpler sync runtime in the product. + ### Cache Impact Test The repository now includes an executable unit test that measures the cache effect directly: @@ -644,6 +744,35 @@ General response-time trend. Use together with P95 to distinguish average slowdo Percentage of failed requests per scenario and user level. Cache and hot-optimization scenarios show 0% failures at all concurrency levels. +### Historical DB Mode Comparison Charts + +These charts were generated before async runtime support was removed. They are retained as historical evidence for the decision to keep the application sync-only. + +#### Historical DB Mode Dashboard + +![DB Mode Dashboard](loadtests/chart_db_mode_dashboard.png) + +Combined comparison of cache throughput, mixed-workload P95, hot optimize throughput, and cold optimize P95 across SQLite sync, PostgreSQL sync, and the historical PostgreSQL async prototype. + +#### Historical 40-User Throughput By Mode + +![40-User Throughput By Mode](loadtests/chart_db_mode_40_rps.png) + +Grouped comparison of `mixed`, `cache`, and `optimize_hot` throughput at `40` users across the measured modes. + +#### Historical 40-User P95 By Mode + +![40-User P95 By Mode](loadtests/chart_db_mode_40_p95.png) + +Grouped comparison of tail latency for the same `40`-user scenarios. PostgreSQL sync remained the most balanced measured option. + +#### Historical Cold Optimize P95 At 10 Users + +![Cold Optimize P95 At 10 Users](loadtests/chart_db_mode_cold_10_p95.png) + +Focused comparison for the cold optimize path at `10` users, where all measured modes still produced finite values. + + ## Snippets - Diagnostic one-off scripts are stored in `snippets/`. diff --git a/alembic/versions/20260517_0005_auth_users_configs_rbac.py b/alembic/versions/20260517_0005_auth_users_configs_rbac.py index b743cbb..6c68ed2 100644 --- a/alembic/versions/20260517_0005_auth_users_configs_rbac.py +++ b/alembic/versions/20260517_0005_auth_users_configs_rbac.py @@ -77,4 +77,4 @@ def downgrade() -> None: op.drop_index(op.f("ix_users_username"), table_name="users") op.drop_index(op.f("ix_users_id"), table_name="users") - op.drop_table("users") \ No newline at end of file + op.drop_table("users") diff --git a/alembic/versions/20260517_0006_normalize_projects_table.py b/alembic/versions/20260517_0006_normalize_projects_table.py index 41a6945..1a66e1b 100644 --- a/alembic/versions/20260517_0006_normalize_projects_table.py +++ b/alembic/versions/20260517_0006_normalize_projects_table.py @@ -143,4 +143,4 @@ def downgrade() -> None: op.drop_index(op.f("ix_projects_name"), table_name="projects") op.drop_index(op.f("ix_projects_id"), table_name="projects") - op.drop_table("projects") \ No newline at end of file + op.drop_table("projects") diff --git a/alembic/versions/20260517_0007_normalize_roles_table.py b/alembic/versions/20260517_0007_normalize_roles_table.py index b550f53..d908ce8 100644 --- a/alembic/versions/20260517_0007_normalize_roles_table.py +++ b/alembic/versions/20260517_0007_normalize_roles_table.py @@ -80,4 +80,4 @@ def downgrade() -> None: op.drop_index(op.f("ix_roles_name"), table_name="roles") op.drop_index(op.f("ix_roles_id"), table_name="roles") - op.drop_table("roles") \ No newline at end of file + op.drop_table("roles") diff --git a/app_core/lifecycle.py b/app_core/lifecycle.py new file mode 100644 index 0000000..660d366 --- /dev/null +++ b/app_core/lifecycle.py @@ -0,0 +1,77 @@ +import os +import tomllib +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from pathlib import Path +from time import perf_counter + +from fastapi import FastAPI +from loguru import logger +from sqlalchemy.orm import Session + + +def resolve_app_version() -> str: + pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml" + if pyproject_path.exists(): + try: + with pyproject_path.open("rb") as fp: + pyproject = tomllib.load(fp) + version = pyproject.get("project", {}).get("version") + if isinstance(version, str) and version.strip(): + return version.strip() + except Exception: + pass + + return "0.0.0" + + +def redact_database_url(url: str) -> str: + if "://" not in url: + return url + scheme, rest = url.split("://", 1) + if "@" not in rest: + return url + _, host_part = rest.split("@", 1) + return f"{scheme}://***@{host_part}" + + +def run_startup_bootstrap( + database_url: str, + init_database_fn: Callable[[], None], + startup_session_factory: Callable[[], Session], + bootstrap_fn: Callable[[Session], None], + close_session_fn: Callable[[Session], None], +) -> None: + startup_started = perf_counter() + logger.info("startup.begin") + + migrate_started = perf_counter() + init_database_fn() + logger.info("startup.migrations.done duration_ms={:.2f}", (perf_counter() - migrate_started) * 1000) + + bootstrap_started = perf_counter() + db = startup_session_factory() + try: + bootstrap_fn(db) + finally: + close_session_fn(db) + + logger.info("startup.bootstrap.done duration_ms={:.2f}", (perf_counter() - bootstrap_started) * 1000) + logger.info("startup.ready total_duration_ms={:.2f}", (perf_counter() - startup_started) * 1000) + logger.info( + "startup.health status=ready pid={} db_url={}", + os.getpid(), + redact_database_url(database_url), + ) + + +def create_lifespan(startup_action: Callable[[], None]) -> Callable[[FastAPI], AbstractAsyncContextManager[None]]: + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + startup_action() + try: + yield + finally: + logger.info("shutdown.begin pid={}", os.getpid()) + + return lifespan diff --git a/app_core/logging_config.py b/app_core/logging_config.py new file mode 100644 index 0000000..f0c364b --- /dev/null +++ b/app_core/logging_config.py @@ -0,0 +1,80 @@ +import datetime +import logging +import os +import sys +from typing import Any + +from loguru import logger + + +def _console_log_format(record: dict[str, Any], show_console_source: bool) -> str: + def _escape_markup(value: object) -> str: + return str(value).replace("<", "\\<").replace(">", "\\>") + + message = _escape_markup(record["message"]) + source = f"{_escape_markup(record['name'])}:{_escape_markup(record['function'])}:{record['line']}" + badge = "APP " + message_color = "" + + if message.startswith("request.start"): + badge = "HTTP IN " + message_color = "" + elif message.startswith("request.end"): + badge = "HTTP OUT" + message_color = "" + elif message.startswith("request.error") or message.startswith("request.exception"): + badge = "HTTP ERR" + message_color = "" + elif message.startswith("optimize.gradient"): + badge = "GP " + message_color = "" + elif message.startswith("optimize.provider"): + badge = "LLM " + message_color = "" + elif message.startswith("optimize.config"): + badge = "CFG " + message_color = "" + elif message.startswith("logging.configured"): + badge = "BOOT " + message_color = "" + + source_part = f" {source} " if show_console_source else "" + + return ( + f"{record['time'].astimezone(datetime.UTC):YYYY-MM-DD HH:mm:ss.SSS} UTC " + f"{badge} " + f"{record['level'].name:<8} " + f"{source_part}" + f"{message_color}{message}\n{{exception}}" + ) + + +def configure_logging() -> None: + console_log_level = os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO" + show_console_source = os.getenv("SHOW_CONSOLE_SOURCE", "0").strip().lower() in {"1", "true", "yes", "on"} + + os.makedirs("logs", exist_ok=True) + logger.remove() + logger.add( + sys.stderr, + level=console_log_level, + enqueue=True, + backtrace=False, + diagnose=False, + colorize=True, + format=lambda record: _console_log_format(record, show_console_source), + ) + logger.add( + "logs/app.log", + level="DEBUG", + rotation="10 MB", + retention="10 days", + enqueue=True, + backtrace=True, + diagnose=False, + format="{time:YYYY-MM-DD HH:mm:ss.SSS!UTC} UTC | {level} | {name}:{function}:{line} | {message}", + ) + + uvicorn_access_logger = logging.getLogger("uvicorn.access") + uvicorn_access_logger.handlers.clear() + uvicorn_access_logger.propagate = False diff --git a/auth/auth_service.py b/auth/auth_service.py index d2d1334..033118a 100644 --- a/auth/auth_service.py +++ b/auth/auth_service.py @@ -8,8 +8,8 @@ from sqlalchemy.orm import Session import crud -from database import SessionLocal -from models.models import Config, Role, User +from database import SessionLocal, close_db_session, run_db_call +from models.models import Config, User from optimizer.service import build_optimizer_config, get_runtime_optimizer_config from security import ( ACCESS_TOKEN_TTL_SECONDS, @@ -41,7 +41,7 @@ def get_db_session(): # type: ignore[no-untyped-def] try: yield db finally: - db.close() + close_db_session(db) def issue_tokens_for_user(user: User) -> dict[str, Any]: @@ -168,8 +168,8 @@ def change_own_password( if user.password_changed_at is not None: last_change = user.password_changed_at if last_change.tzinfo is None: - last_change = last_change.replace(tzinfo=datetime.timezone.utc) - elapsed = (datetime.datetime.now(datetime.timezone.utc) - last_change).total_seconds() + last_change = last_change.replace(tzinfo=datetime.UTC) + elapsed = (datetime.datetime.now(datetime.UTC) - last_change).total_seconds() remaining = int(CHANGE_PASSWORD_COOLDOWN_SECONDS - elapsed) if remaining > 0: raise HTTPException( @@ -183,7 +183,7 @@ def change_own_password( db, user, password_hash_encrypted=encrypted_hash, - password_changed_at=datetime.datetime.now(datetime.timezone.utc), + password_changed_at=datetime.datetime.now(datetime.UTC), ) @@ -205,7 +205,7 @@ def get_current_user(request: Request, db: Session = Depends(get_db_session)) -> user_id = payload.get("sub") if not user_id: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") - user = crud.get_user_by_id(db, int(user_id)) + user = run_db_call(db, crud.get_user_by_id, int(user_id)) if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid user") return user diff --git a/cache/shared_cache.py b/cache/shared_cache.py index bca1322..cb69d1a 100644 --- a/cache/shared_cache.py +++ b/cache/shared_cache.py @@ -2,10 +2,13 @@ import hashlib import json -from typing import Any, Callable +from typing import TYPE_CHECKING, Any from cache.ttl_cache import SharedTTLCache +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + def _canonical_json(payload: Any) -> str: return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str) @@ -88,7 +91,7 @@ def build_prompt_collection_cache_key( return f"{PROMPT_CACHE_PREFIX}{_sha256_text(_canonical_json(payload))}" -def build_optimization_cache_key(fields: dict[str, str | None], config: dict[str, Any], backend_name: str) -> str: +def build_optimization_cache_key(fields: Mapping[str, str | None], config: Mapping[str, Any], backend_name: str) -> str: token = _normalize_text(config.get("effective_llm_api_token")) normalized_config = dict(config) normalized_config["effective_llm_api_token_fingerprint"] = _sha256_text(token) if token else "" diff --git a/cache/ttl_cache.py b/cache/ttl_cache.py index 8601f01..6f08e0e 100644 --- a/cache/ttl_cache.py +++ b/cache/ttl_cache.py @@ -5,7 +5,10 @@ from copy import deepcopy from dataclasses import dataclass from threading import Event, RLock -from typing import Any, Callable +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Callable def _default_max_entries() -> int: diff --git a/crud/__init__.py b/crud/__init__.py index 43515e5..1ef5bbd 100644 --- a/crud/__init__.py +++ b/crud/__init__.py @@ -39,42 +39,42 @@ ) __all__ = [ + "DEFAULT_ROLE_NAMES", "_utcnow", - "normalize_project_name", - "normalize_tags", + "add_version", + "count_prompts", "create_project", + "create_prompt", + "create_user", "delete_project", + "delete_prompt", + "delete_user", + "ensure_default_roles", + "get_default_admin_user", + "get_latest_version", "get_or_create_project", "get_or_create_projects", "get_or_create_tags", + "get_or_create_user_config", "get_project_by_id", "get_project_by_name", - "list_projects", - "update_project", - "add_version", - "count_prompts", - "create_prompt", - "delete_prompt", - "get_latest_version", "get_prompt", + "get_role_by_name", "get_specific_version", + "get_user_by_id", + "get_user_by_username", "has_duplicate_prompt_version_content", + "list_projects", "list_prompts", - "list_versions", - "search_prompts_by_tags", - "set_prompt_tags", - "DEFAULT_ROLE_NAMES", - "ensure_default_roles", - "get_role_by_name", "list_roles", - "create_user", - "delete_user", - "get_default_admin_user", - "get_or_create_user_config", - "get_user_by_id", - "get_user_by_username", "list_users", + "list_versions", + "normalize_project_name", + "normalize_tags", "resolve_audit_username", + "search_prompts_by_tags", + "set_prompt_tags", "set_user_projects", + "update_project", "update_user", ] diff --git a/crud/common.py b/crud/common.py index caa195a..e9d0c3b 100644 --- a/crud/common.py +++ b/crud/common.py @@ -1,8 +1,8 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime def _utcnow() -> datetime: - return datetime.now(timezone.utc) + return datetime.now(UTC) def normalize_tags(tags: list[str] | None) -> list[str]: diff --git a/crud/prompt.py b/crud/prompt.py index 6ff5a5a..ef204ce 100644 --- a/crud/prompt.py +++ b/crud/prompt.py @@ -1,5 +1,5 @@ -from sqlalchemy import func -from sqlalchemy.orm import Session, joinedload +from sqlalchemy import false, func +from sqlalchemy.orm import Query, Session, joinedload from models import Project, Prompt, PromptVersion, Tag @@ -98,7 +98,12 @@ def get_prompt(db: Session, name: str, project: str, allowed_projects: list[str] query = ( db.query(Prompt) .join(Prompt.project_ref) - .options(joinedload(Prompt.project_ref), joinedload(Prompt.created_by_ref), joinedload(Prompt.updated_by_ref)) + .options( + joinedload(Prompt.project_ref), + joinedload(Prompt.created_by_ref), + joinedload(Prompt.updated_by_ref), + joinedload(Prompt.tags), + ) .filter(Prompt.name == name, Project.name == normalize_project_name(project)) ) if allowed_projects is not None: @@ -118,17 +123,18 @@ def _build_prompt_list_query( project: str | None = None, tag: str | None = None, allowed_projects: list[str] | None = None, -): # type: ignore[no-untyped-def] +) -> Query[Prompt]: query = db.query(Prompt) query = query.join(Prompt.project_ref).options( joinedload(Prompt.project_ref), joinedload(Prompt.created_by_ref), joinedload(Prompt.updated_by_ref), + joinedload(Prompt.tags), ) if allowed_projects is not None: if not allowed_projects: - return query.filter(False) + return query.filter(false()) query = query.filter(Project.name.in_(allowed_projects)) if project: @@ -282,7 +288,12 @@ def search_prompts_by_tags( return [] query = db.query(Prompt) - query = query.join(Prompt.project_ref).options(joinedload(Prompt.project_ref)) + query = query.join(Prompt.project_ref).options( + joinedload(Prompt.project_ref), + joinedload(Prompt.created_by_ref), + joinedload(Prompt.updated_by_ref), + joinedload(Prompt.tags), + ) if allowed_projects is not None: if not allowed_projects: @@ -303,7 +314,8 @@ def search_prompts_by_tags( query = query.filter(Prompt.id.in_(subq)) else: # OR: at least one tag matches - query = query.join(Prompt.tags).filter(Tag.name.in_(normalized)).distinct() + subq = db.query(Prompt.id).join(Prompt.tags).filter(Tag.name.in_(normalized)) + query = query.filter(Prompt.id.in_(subq)) results = query.order_by(Project.name.asc(), Prompt.name.asc()).all() return list(results) if results else [] diff --git a/crud/user.py b/crud/user.py index 1a32b8e..bf17e6c 100644 --- a/crud/user.py +++ b/crud/user.py @@ -1,3 +1,5 @@ +import datetime + from sqlalchemy import func from sqlalchemy.orm import Session, joinedload @@ -97,7 +99,7 @@ def update_user( role: str | None = None, is_active: bool | None = None, projects: list[str] | None = None, - password_changed_at=None, + password_changed_at: datetime.datetime | None = None, ) -> User: if username is not None: user.username = username.strip() diff --git a/database.py b/database.py index 05612eb..82b4746 100644 --- a/database.py +++ b/database.py @@ -1,22 +1,28 @@ import os +from collections.abc import Callable from pathlib import Path -from typing import Any +from typing import Any, Concatenate, ParamSpec, TypeVar -from alembic import command from alembic.config import Config from sqlalchemy import create_engine, event -from sqlalchemy.orm import declarative_base, sessionmaker +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from alembic import command SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./prompts.db") is_sqlite = SQLALCHEMY_DATABASE_URL.startswith("sqlite") -engine_kwargs: dict[str, Any] = { +P = ParamSpec("P") +T = TypeVar("T") +DatabaseSession = Session + +sync_engine_kwargs: dict[str, Any] = { "pool_pre_ping": True, } if is_sqlite: - engine_kwargs.update( + sync_engine_kwargs.update( { "connect_args": { "check_same_thread": False, @@ -25,7 +31,7 @@ } ) else: - engine_kwargs.update( + sync_engine_kwargs.update( { "pool_size": 20, "max_overflow": 40, @@ -34,11 +40,15 @@ } ) -engine = create_engine(SQLALCHEMY_DATABASE_URL, **engine_kwargs) +startup_engine = create_engine(SQLALCHEMY_DATABASE_URL, **sync_engine_kwargs) +engine = startup_engine +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +StartupSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=startup_engine) if is_sqlite: - @event.listens_for(engine, "connect") + @event.listens_for(startup_engine, "connect") def set_sqlite_pragma(dbapi_connection, connection_record) -> None: # type: ignore[no-untyped-def] cursor = dbapi_connection.cursor() cursor.execute("PRAGMA journal_mode=WAL;") @@ -47,9 +57,17 @@ def set_sqlite_pragma(dbapi_connection, connection_record) -> None: # type: ign cursor.execute("PRAGMA foreign_keys=ON;") cursor.close() -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() +class Base(DeclarativeBase): + pass + + +def close_db_session(db: DatabaseSession) -> None: + db.close() + + +def run_db_call(db: DatabaseSession, operation: Callable[Concatenate[DatabaseSession, P], T], /, *args: P.args, **kwargs: P.kwargs) -> T: + return operation(db, *args, **kwargs) def _run_alembic_upgrade() -> None: @@ -70,4 +88,4 @@ def init_database(bind=None) -> None: # type: ignore[no-untyped-def] return _run_alembic_upgrade() - Base.metadata.create_all(bind=engine) + Base.metadata.create_all(bind=startup_engine) diff --git a/diagnostic.py b/diagnostic.py index a123543..c24badc 100644 --- a/diagnostic.py +++ b/diagnostic.py @@ -2,7 +2,7 @@ import requests -def run_diagnostic(): # type: ignore[no-untyped-def] +def run_diagnostic() -> None: try: # 1) GET config config_url = "http://127.0.0.1:8000/optimize/config" diff --git a/loadtests/benchmark_rps.py b/loadtests/benchmark_rps.py index f65db92..2214b91 100644 --- a/loadtests/benchmark_rps.py +++ b/loadtests/benchmark_rps.py @@ -8,12 +8,12 @@ import shutil import subprocess import sys +import time from dataclasses import asdict, dataclass from pathlib import Path import requests - SCENARIO_TARGET_NAMES = { "optimize_hot": "POST /optimize [hot]", "optimize_cold": "POST /optimize [cold]", @@ -107,17 +107,27 @@ def _fmt(value: float) -> str: def _fetch_auth_token(host: str, username: str, password: str) -> str: - response = requests.post( - f"{host.rstrip('/')}/auth/login", - json={"username": username, "password": password}, - timeout=30, - ) - response.raise_for_status() - payload = response.json() - token = str(payload.get("access_token") or "").strip() - if not token: - raise RuntimeError("Benchmark login did not return access_token") - return token + last_error: Exception | None = None + for attempt in range(1, 6): + try: + response = requests.post( + f"{host.rstrip('/')}/auth/login", + json={"username": username, "password": password}, + timeout=60, + ) + response.raise_for_status() + payload = response.json() + token = str(payload.get("access_token") or "").strip() + if not token: + raise RuntimeError("Benchmark login did not return access_token") + return token + except requests.RequestException as exc: + last_error = exc + if attempt == 5: + break + time.sleep(min(10, attempt * 2)) + + raise RuntimeError(f"Benchmark login failed after retries: {last_error}") def _run_scenario(args: argparse.Namespace, scenario: str, outdir: Path) -> list[RunResult]: diff --git a/loadtests/chart_db_mode_40_p95.png b/loadtests/chart_db_mode_40_p95.png new file mode 100644 index 0000000000000000000000000000000000000000..5f8887e3952d5dd763f88f7e0798e947ad167845 GIT binary patch literal 37123 zcmdSBcT`hr7dMFQih321i&!Yv1_}bAfYO2mL8;O;G!>O5AcS5mC{2ip^rj$ELy^!+ z6e-d>NRdbhRfrHEq>gJat;2gl|G&cEO%_@aQ(L+aeiZJ;s1Ep!_phbf@e^U1{6Xd+SCDQVNcxTzlC0ulhdA(<)(y zj?G#HtJVc|9tcwst`8EIHBhM=vAk4go`3t&wq5_0vgbSrj;qQho8&5*C8)k;nAS&= zl~W0F=L&@hlom>HRz^m~HMkBAj?bnxO734bZG8VkVdIZ~RG$Cmn~Oj9Z2V-~-qJ0| zHy1Z;UD)`+4UYf5_WyG)kb|^7 z{4FtcrO!l?&`RFJFDR5R9ohNI1_{FKJ&0+rd$RJB8AZeNt2$9H$xBB*F7_N{wzDRAB{Bk!)UG#hq-NW7Bkth4Db4p&%#HFxV~CKAW;`TZ{bRq8xNv$ zGVuyAubM8py@E~eCphObTF$k9@TLCfkCphwqoep5#r*x%C-k$DXg_tpDw%0&`g5&L z(ogq3YbwcGLRpC1C_k4I(V%vl-;zvVuj7}R#HK<|vl!HNvcIo712=CZ{~~HSBQ-qB zCsD|ES$Ew}<)mwN9I&jL+o}Q1*qbvTTO3v`LA!_d=OfZqIIRP1Q(+I>soMe@t#QeHKrf zj5n_4;+}{~fcr1k5hgls;#ehvBdpUsJ@H%vcSFUUrw{Q`(kzah@h*3ndE++h)L?o< zFnQj(lLr^+HsU$Of44o@ZGMonleUI>ZvFM2P0Zl~($+Vs3BNuy&zhz$CydN+aMU3c z;l@nsd7>tP%_3u7tsxg&+@^Wz-(lNqoGmU0J4@sY_-bfu);nRvC}8V<0Wb==$JE^ zY)v?;P_<}f{4jf2>}g0yNR#MXMpSsy++N=4qN106-9=+w*X`HDg_6EJOZL^}mI zfo`PnwQV)Se=RJ=!N>A9F5V|fZBrmm?Aq2TYW4H;lfq$hpQxknh+cCz3ggq~&AUF_ zkGdtynh;Tr#Z{A?q^!EN&bJc0fW zli5tG`xW7wcE>#U72F=mj|T8`O?Rf+&Fc~t%I78sU+8lIh!pe9kE6jyR-W<3_{5 zo5i>~&A`dR`S9}zOsbk1gzVbn#BDk8?RAl#P|EWTLGNF$B^7(8LM5xe5zK?Dm#8D& zj&vrao#Orz&c?T)@LjqD*1V&gz~Lue_{@-9mlx+^*A%1tbQhZ~)}4#d+=-r(u{OA( zz9!cEc*Q^d#&@6oTyt#|TK^r${1MV;$ibnd1s&_+{!2y%6{;Dlg~_ak#cfR*l2rx+ zl!||(`#vt4;^l(#%TLwTJ-NcABpv9_&Ae*O_r-Z@uj-$XSOv~t#_65L)ldHcC9 z442hYxHbFJmQ9Bn80cuu>Qs3-KY_hf-`t75RQ{lLhe&7NyD1}UycZ4bHOPffg@w7p zEQD&OPS*^?{46?txnJ+G-%MI`-+(_VD?=ms>xK0f2K2OxrnSKqFI#dsvExq-GB+zr zgugA8Ie&C|Pp{;)S_UsqR#cvTGUwoUp%C9C*sYaJTG+1Ri_K5F)(YEE3jSQScKU{N zQtgpI{x`(&HHEz;5!&*n%)IA|Xy*)~Jsh!elQI0ilidbw#27 z*`gdAw{u}-&k*C!+__wr5-Nc%`1y2~QRO;)E^=zTmPhC1KQcsH*uc#6C7M;TZ*d#N z0}g>vBvd+Ez=A#?Ej0*wpO&2aUSL^QLiAG@VxVXf z5tLH#lHpHxq45^wV!rM^Kj8}P3T-)*W_j!0>#L$8P|OEL$iJ#{-+U*d?QoIFjz7v> z>yUmE-5w*Anxdbs?MZ`nZ!xtBZ^~$zN6x8XQUz=@vE=kHX~Ip4|PxOT%hHz6PDp?3u#??!&s4zj`wD#gLkr ztW2(?Os;)bZ{Q_9A6UftGgF;g;Bc9d%GQ`sBgqaw$_+=xFx^>(+05a68mBk*@29bD z9c5h1Q2X_n;W{hHVvF@3g6AG+ny0H_1!=^^SkbUhs9LF56q+^+FvMWa57{zJE5C{~$6>z}@*1>T%9ME0VHsNk-1vAQVZ>{Wwwc44 zQxvMpx5%%$TKv5qJ5wy(u4ae4$w1*WXc&^^A$^1BXs-gb_%{Q*y+ud7@)~NRih>2l zLr;IYws4Hqqp#Cj-UbKWIE|#Co03B9R1<3L?MQPw-dUkQ{~c)kgBxdjS#yC_f7r3- zyQ;yC=n}@MqSRB$?rH+A1`DZzk#)KQ{JnKZ8Ku;g9-)6HS5ZeWE9evg6;^EgP;+Xu zcc$6(WqWM9?R7jVM3k|lzUq8__|X+Vg5}{wL23Mw#r~cZyYP%!teZ-jiI0OiVQ&yw zt+EEi%wKl?1qX6I>_ z{BSc#%8|NEin2|MG*VZlcVeCA!eerf8*aJh<(ysN(ug;iv_78}bx7*Et$j&aw0Zfb z$g4K2!J3H_6~~3HdpS~@?k}fFK2PY}cM|RKR$+Z@93%G1ru!qQV+k-;`xDR15_kOA zN%ARTxoa;wes6LZT$Y%9>FA3{KApS7PJM<2n{MpT%i~bB(kK0(C5Fr@Pm%eSlN6d? zUWwB@xzbL6R_Z*IGK+h2dHIy2nTw6B)OE+LKS?EPOcj5MhyPfDL*-)q%xg8lQ}_JS zZdCOYDoJFhyb|EEe30&IV6%B-y4##psEgWuQS%+T+K)DFt&BjQetB8+KR5urMJVIA zV@C)1dskSYLyaQIrerD?$GZZgZ%tb2nkl$UckowUJtLRSv*MwUc~?MY=MFBWH#jD(uUD*X**Zd@-$KwlA`RWZ2m2n6^IZ znix1+H9Do*pKGRH;_zchJS*I?=G(M}KhzUa$k;5;ZPnX?d`2mQa$fz^`JwZ9Jb5e; zW!m*U-l=@PjbovbD76Nj2%{*`x8nz@^Ym^jWKX4R_b3C9qPPNxx)n;qCufC9`fPR? z*<;3-KkR*FR@JiDvjJ|hn(2k$e-*3wbbAHvc$oRlOsND=xr5X!Xfap*73~|x92)hb zF8r{Pt9GfL^Y*_y!^ck|wgk`IAHoU^J2Qx74xY-;cI9{GC>eO0zI*$y`f#E7~77H&PKpGb2ZD{Z10?!y_T zJY9#T(=_T8unw1QK`G}pS7TG&To`#1)sc1gIj_+*XgY3tevPlFboc>Ko+73733UpJ ztpE7u0X0R-p0Gb?{g>_u!6*Kv7lZpQr6rNV41WlwnCWENw@{U^QpI$47l%4putmPg zuJ_Y4p{ZJWgi+t-%Bx@>|oX&UHwdal(C(oMF{zXK8~I36+Q8a z!i70nTc^{4|1N=zO)g5-^PjRx@)Xt?yp@aqB%)e;WVx`MINuk`t)Du(tMJ&a4_VwX zQRq7={=cI{M^d2{3egsSEidOv_|a~ZXY3RAuFmr&SGh;E*FAr0z0JTx=yz9btd!?0 zY~+NY*xEe)lporbL1!vl1ZU}KUw@tCD$Xw1POm;%I^xtIKzSbYyE}PZ+QKJJU@%L`nsHc3;(LVg3di3a~of1_*;z8xKL}ii^KUi-kn1DF`Vk_ z<`&*BfHD<0|LFRJX;&&~l2zv&<*rwP&Lgwx1VquW_#h2+$00LOCDW|Mb1Knz%1i3Y z*cL9u8*UEksPzV|D40tJl5 zX?mei<+UGjIxQ7cOybF65>W5aV+^8M6XFGDwVG6es#5HVuhrD~GD zIcwaY5i6duJjD#julnbvn(I8$zsN<{X@bCz#PbSne$~2%jlg1GZ&~zX(MjVsL3ps+ z8X|Sa)}UEcEOrSs-g!)3^ml5quZ;yM;)UvkgL#sdiMZlH(URMz3_k~Qt}T>~hx zg{zhLq|0=N6!xM|!+3@H&Rz|k=_rYNyAR8rR<4GY0XwgYDk3^$$%=6)$))f%Yw*#= z6!?>$m*yMO`RjdsB*vd=wp@8Mhb!@GF9iDd3Y4~tufZZo2S^`iM#)ex3XubyVe0<= z`Tnfwj`vqcg#KGri6Pe`gj;((lN3FjuM(?$#YzsAfLLRAx_WJl7l$s&GVM!M!-hm# z_?6YKArQ0a`?mcz-=%0oOYq`MN^(v6s=TQo(dJ&6_1^&r=G_HdBG*)MwdDKVP{Y%o zz8y6zE<-xr0TZtUXy}RA{T6;qr|PL51nkQBjXvkgz4_?NHZk^(0?URFvAoJpZ3U#f z0<#J?T4f{_JEW~J4^j`=66k09(1oc4jHuV@uUoYBw?dc%3XmO-sG;)ZE)B7U9Ns+5WG0N1rQ8by{wOA+T0W!keaNoO2&z8y;W5w*VT|%iJe7t?>%QW+;LMG;{ z$k;hUqSXqeT{-W&bF;Wuzs*pweHeK}S53fhuGp$A;Z^}4L&IFYJmvagn{HvYcV5+M zmg0FQi}+oVmH8>Yj8&Ox!lLJCWk+KR-xdBU2ad-nnb4?i=U&aD#5_JXsE%A*8Css6 zu2$z{&|QB#jAS@YRMY%^$hRle>jK(TCGU5JLs-%RAnsfNPo>#(jutM)_VmQTNB z=a!4^7F4_d)C^q~ColTOvO@RE;mG3g`Y;UyHrc)m%OYF5@57<)Do7Pr8?0$3qm&4% z897+dVOZ0Tp1n5diBZ^d;pej&EH}4i3}df9O1)fK?WWwxqts@yT!(GEpL$kqWIWOc zeTu4u4Zb|?8;p@VKXz_uZ5%sgarkt}uyfh&8^0vO6$ZCO`ibi%XVivUij|iup{#gB zv|CErQH;}6Ba8cyhF!4VquS@Q^Qz<8FY!}zYNv|vxGr&&Mzci4Lx)Dpd{O^LDa-nn z720V%AB(Ymu|Z^rLWG|uR~EN(Sc~G0x;lQU0$uf= zlY^ZqL^7u<5o(+23=uTf69{mBynh^dyK1bu)g2II!w_!Ef#>w6HCs5FBwOFv7g4*J zW+_e%orKnL~#So+fKQ z_<`5hRBHDj>65^0oV0vza&WYyYsMkUH;CRHUj;V4*@-|I_*0wjr;%owNv~^h3CS!bdJ;X8v#c>_?98 zk3M7r0FI}hL0-WzdFpk%s^8#VgVK^4X-%s@y=7M3Z~UK@b6$YmZVwrO_J@4^Ka!#c z_ZM&@f5}>%mzS6JSBo;b$TrfEvhR<0-P#2VD;kPO=rX7lX@JUQRz^9rZa>+!{~B^^ z(Jq}=_2>Guh#glqbnuIXJiWh!{MPR58Gb_R$J=x-5uYLgWm*(Et5IxZ8(;z;5DNMq zH8cMniiXpT|8_L=11z@m%AHMN){`xSWBdJR(aDRH< zmZ*>}VOB1UT*f;n5;TUgW&KNOo30N&M{+Z_P(M1_Zs_osX%GY3sL0XAY^RhxXOqdF z;avCj1RmYEWAA7yj##68X*+nH&`f5?7nx>31TL-Xe`Nhub!T8(~lZ~H{wg1pkPkYQzSHjA2!8=vb=*D+@U$%xDD z-$BVL*78LzfTLrtmzkSG;e~?nJqOU=+_XU%a2X30nQVYEV>@V_8m3p_Hs7_jINps& z)vDqAv3(Z)Ec{~qVdH~{SoZ-a`$s-=k0aaXCvD05TV_)iGhfl__^E)BhU4(T*{iT$gW|Z z6-7s&-Zb;OZ~?zDgI$to*Pm-xv&LE-k^~^`3&qS$-LfHE%YMj@eQhx-J69bp&^7mS zVAM5@gUCm;o=kbFG*}IUR8Xd7H6Jk(kZQr2R#VRU#Eh{FZ@j}mKn8zLbm07*GE$TV z98)zkNOfJvDc@lDy-xM3V+Y1+IPfZePNnrE3izcDp?04}%W8~{GE3UblN3o?4B|ef z7cXmXYvu@TRULtwlyLxc=UWrxDBeO=D}oDYg1<-SONeb)jgp%srUv!N0M~62UHh&l z%DXm|SY!3Qc4NlixauI(Ef#4CH`jti+R7w2g#Hz10P7c|%< z!nkW>I4j<<`Z_2*4;>wvf;7 zFZ=}6xEFuQOI(={^XGONNKJV8!d88%_QlK#rje-ZQaqIVS*bfgWu2%-T#fhK>k>Wn31Og z=u|cp^To}UsgCPLCiam~TdVj#a~+n=sV_G7nn}a24%($FGe@~*B6LvK!BX)}`Yc;R zLl{1SqzhSeYqZyfvv%Z%!{#6u{ zLTw)9%uEIy)bzf2z=itKtC3##!r&0<;NQ@Hx3dC;{5*2^VM7+rIE~ffDQybrfEknn zr%6YA&Ds`N4{&jUuI%-I1bBNf+&9gYTl|4eg# zUFD@erA8W~d&d{3=Y%>H=}8<4Pk(~Pbbk8)(wxGIk9XGG(;nD$W6a{W{Y!X|0-->%G)tE z+}n3fd9bB+D?Tk>sdjxVdUNL?ks9jKI%B*6+fF!pf$^&_J7E4uR35%dy>MmF+Uq~( zWm8H~BWV6nTZTD9jy-e)NQJ1;m;(a5gWSK`aAh;2>%PNq!8Fv1g#f0uQLnuCJF^grY7P9e$I(HY{;5jWxBk*mS~19-3~%W4SE9dbPtx1V$AilojpOlt zQFOXWkvC`^`s{TUhHEvW$K3k;^QC*4?<=dWn?Vz(Xi6d%#@#NmE+Fr%8VTi0OHF4% zKZsEcI}~ZsI}GJ-hR9xbudb`jStKuIt;8tG&~w#HVmXPaZ&38ch7Y(R!-Bv;(ZyJ0 zKov2TN+m+mIWp(Q$@~L7flwNy-$RX-C#r38$HxSJR*Jf+yv*5k?6LWVO(8V8{NZ|p zvmx`vVTbQQYU}Lmy4185(6(-UlTCG2DmI=a2;%O8p_Z&TU}KP{*3MXxJ!4{DWOxYb z2As%fQAQbijE`_~;${lj&jXZ^T?2wz&1w7p6lGCS(OF-VS4CjPutZTn@NY%Y;;*p7 z#l#x38I$pjT_Axb3JJM=zf&gZ(>YjV7Y!#tcMgM8ML^f}hxM-nXR$!A>!&S3Z@w{3 z>zc@vvY8{SujCDJQzz?=`4HR``D2O&``ut0G|>U5Avo9%ULBr1OC zrI|GevDX^#H9N&VfPJh$m0~Y<>tw$STOUI#uUrnIOSe`x(BA%k8-&Cig|!o6vdP&) z&?B9v*P*nLZukJ?km(B+Wo6VPM_Q+2z~JlsuCw(=+G1J=#aVZUP}N8(U5Q>t25GrW zy_$2&Uw?lyT;^VGcpm0RU==q}oEC^4<6)OquYT>;Lvj!tab=uO)LtHV-3BC*e?-+g zhBH7!Gv*bzI|ck>xs^*zGaxVFI>%Wc#J9hve|vqb8|k{URg?NN+m~m$DPD*SjdrYBS*m++a}|7w3Hw@t zogFU^@7i|$M;GZ|h1PFaE5z=dT88e@&d2+LODM_0V)+f$LL5;D<$|!35-9Wgi**i# z|F;_=`Zb-jwSEOi`!#V9P>Nk&H zXAs$we)DOo!dtfQY2gVew(ZSoWBCI+rx`^9?(FqurYHM(?TJCWHlM$_mmJ|OBA+nc zXh_5g`mK!)D=VSPYZvPY;JHUVo3?f>ghSrCr06=^bIJ@_*G!W4LgUn_8hXYW7IWs_ zw^uITjs$)HGzluMguKd6!8;zEJPn9HO!H_1_Oc8mUPNc|5Nl<;G#tzyFUbdI9BmAx zvj5VN)<~1|Wln5;XDzH{?}t!=iyHeb{RXr}8P9m?@B`gH@nUa803uj;ru1gyFZAbG z#3f%fNrD=1!6N26P?&i7qnJ>_j@_||bUPp>92`A2Y>)A*ly?$Ya8hF(+q<9@CL&tp z)DEk0(~D;Kw;RaQmUsSMN{KgK!4nn+V;4b3H{+!{$4(5k)qePdWGEbb3qc5xUAZI0 zwLxe&I9vzqOMWcb`~4H~rT)VwsY`PpCd7ExUGe>Mt~iQH$<>SmzR4iL4RFr!KXWVZ z>i$|lx(pJ(KqIse$bQ^@;A(idY3a$VUsf}S=872xqV!sJ_|aD|f6{x(FjM$$ZG|*w zv!x@xO3NKrJgaUh*VnB9G|d{3;t|%o_G~*>8!H(xy1`U>&A6zQD8J%Ocz~<}N1S%( z?qT-tK;;h#3#G>be=4spV)dbUV)9v30$T}=iPub@i6c(x0f--RTm3i*WEN(5T9JYK>q+mKz8@R zv&Rwf+?fS=m2~c7suLVcD6y@ZyaT2;VD2Ek@e*??+28!WP=v6SI&!}DG^+tH7tsx3 zfG#74gR+2RYKpXT@&QJr>!4`I+im%KSCM9gDvp_#%OU|_T~?J-+qkNK(&6?%IKn2^ zI&T3KaBVRhUN7!~(_OE{I=*cDyB!Te{yq*@^t%vP*Kuh(DW3)T58Njl__xQkk`(2P zGt?MyZ@`F4JJ+swPC2}zY8Vc0(v_7H(WalA6FZ9Shg@KFKSIzU4Ny{EQ{Bez<(Tvp zmwl9{6EOH5Boo>?0$+iFHDb5#;lF9g>iT5jP?pxT1O*@0CY82dR#(nqB)UraqXogQ zEYGKma~3o#ifJqx5$#vzHa7pe#TNG`-ph}$tKzzK=2`c!{%XYK6Hzyhii5!_Q=fzA z2I~M^vp*@K+utf%NYpR^K_~^z=fRpi49j%1*toKmp}~*)7r?YqM?@9Sa14=lwlu!* zqaqyw1;W$^TwqFSSLca8XvlF z)os-fEoiy%_SPu0&rd&a!9GH+Bz=hpGMZ00WCFUtKs7G=1^?MX-6f8{kvJ6Gp+ql@ z-c0R^q1V^N?6rL0Zc!ALsRLNt)(ipa=}7!ar)}pAJa2_NMlb##TM0mYD9k znlvC9ANz1q6~cRpM2mDJYTd)s{S z`R})%;p_IBe1O1AdSACJuwoa``_rMAC{;o8EgP!S(-4mwQbij4H6(t48{eb&`}1({ zvB@CjK)c_h_^F>tIS_n1XVuoiX1g2t^W3e{lXB}sGc8i5-wTQ;WOX-$3&an7dkE!s z1`fL2>=lG>Kd!l2Dp&$-Spf%YG6PLUd%J+k4fDR6#RCuGeK!4sgR4&YG}E2FVZJji)`_v5~&c^W184^>m>x1 zeVcHsKYnc|^^Nro9^nb8Sv5Q3bBxtnE95k9Zp%NghZMJ<} zfqSmCe$ks6xiP((g02__@|p-Kfb?auKU*Pi0g?n5TkAA+I=G0kd35mj6BO2q4DhpKi(PfsIzk@a1JJS2_92@`K>v1-nFdDN;WHJ zibUy7SQo}aLazt{IczrZmSO3616PKR?x?w$&todU2cP>hP~RGx;a_Dw%6Do46DdlF>$P{?1@6;LnC|mCu0!SNGyBEv3y2YW>qU41;Laz1=|@4mYF zw6YUq_C?205_|RJ^9=5&C>6)0n0!iRdQuZE=2!_G_MUSP|E*gqQQwqV zGdh^Ziin{yuIQDlc0SNLc?klQMV}I#4gA&C4kHc^liY<6)sfvGqHF z*6O%~Llv+&S*f-@*|_2+US`@(3g^i9*k$~a;FO{74gu?(+Ex%%X~8H)g+7w6ok-KY zsGtBEbFmK_nBl#s`P*X&&#g_m_f`#KQS@m%0FsYzLeBEh_keoTe&=8WA*!U{)XIDI z5OLjT&h01<3na9q=fShM+*FDhetI1Vaw&_vHbH2g=UD#*$6lr1pSRFzwD5Dmm_xC) zFN5bB>fXed7N4+ttu!p;HT|BccH9deO`Quqf1$e8i6n7()mmKNxt3dPB3oYyTD4O! z*8`7<*J@o5G*kC;RaOBS@jnbc^ss*RRb+&K~hfA|M!i zF98%3v0X-~j4PREiY0Wl`~lZanBpq=+ztBG*7(cNl_Mtu#1BIJWyDyK4O^eI!|W@p z7e$d~lS6>YH02{%LxL1;-lv--<$qL^y49SVg8DWKM(2FO>FpJoN-aOYbIGSNFKw5s)170&(MqJZ`p&B8 zvxq$V`h>aT*7etAQB#FZnv1b^jp%#Ny?t(hYS-X%!ah*I?VItNVz6i+mQwR;?N!6} z4=&rpYWaTu{>s(P_H{yOG15oYztwG*tyi%YGG2w$BT=Twb2HqHcceHu%!9VZJr{mH z=66xODev+7CvzBl+Guj>cF$7}xumS_;Ex7pQBGbKFN!)IrSn?}A5All<>#WLqw+${1sTpT@J@P)exQZHuR^A1NTcd zK>F6^yO%pT(~jkYH<74d?|2hV=^ha};)Z{oiKAClie0ronismh*c(ZYh%UcjV7&8P zw$ANC;t!8r2oGT+IA>|I%GO5R1BdPH0HVhm)NXrq^y;H3NajsaPx?un$u<(#4Hire zp0&TN-aAi#JhQC;U+;RaLYZ$Uug4+%z`>x*d~c!*zs>ma@`au&-NeO zkW)W1MMVdvj10aRmq5>+nq8Wn5$K)Z9#kEQExl?-0$cDOT;(>cGN{E8#Auy1bbOfX zpmh`@=qDXuLdz3cGAQh(0~R}nEuFijKXUU#s_LxKAql@rt|vT%IcskbvR6Dewa9h( z9Tv6HiB;abTzb_ie7eG7;oY-6O;5Z{6+yc4Drndi6r)(;r7-_y*8WaLa^Z#@MtPml z5iYmqm~~jv6h=>6(6>2NrV-J@mjPH#=5pz>1bKe~$dAXd)Y4o%Wn5fc#gilTPPlDl zJ9WO#HK83(F4T+rTvREFn)Lmf`+=Hw63CXx>=r7bLpe-!?HyyPi6w9jq{?XH8^bZ& zJmad2IcKLY#|Q5;V01l|v)#^OX1BAc+0P*fqSTuAhbx~EvVq(cf>tTxNYJLp=)Rgl zEZCN5pkP#th_1!(7djHFX++W!@2&JBqP2E&I|L19b&m*ca&qOK^cUi`4UCa=ax*kA z=wbL6V2ugC_u|kg>Av`(;OmBSNSZ-$z&caM$1<2IJIH)H(mYGP6)@L%g*_3W(?+>o z?-PaT+LwVEViNBab#nRpo%o|j(pr#1OxA%h4bcdD?bx#?1f0JekqZhBmeiOxNdhH& zIOOf3n8J$r$nV!M0~AhY-VX;ZVUv`$IInl&(PD%H7ubJ9WAR`$>9cY)3~X+DAI zL#V#lyUMzeC_tE?gmyD4DQ(SpspTGK=?Tv2eu|HNeijusN{LgH5KZtH3!dTfU%L{v zYZthfkE%KLM&MKl3~QnuzVs$HF9UISB1zmIiEvI_R~v;CJszRKO6&q^7f+nhBhrul z4K$jXg|$GvEw+%LkNQJ{%{Qec=PA2@eb_9sEX7Utm5qzrGj9eISDXGYfW1>JYs~6jKoM;I(NWN(?n?lyg0qwvdOs0_6vP$?gowxwfVYX>mV*p?U#32 zeAnYAJlL-?Ep*4%fTEVXGQCYmTz5moavdwGlZqU={cN|h1r=P3V^lXRij24jdzcmw z_4OJ3H>(lZKT{X0CAg>AD|!C7!WC`bUb@rke`_e;)hb06^m zu}ueG5&Tn$Uc#4#nQEIUpUZ6~`x~q@)V;i5>@+$GvC1aGfglQD;*G|~DD^)xNtE|7 zzoefoaQYl$hKtT~5(4)K#C`4J#A+OUtPJ)Y=3ha>IYggB`z+RV$Jyn^B2@5DM4)QN z;Ug}~oJeX?H2b^86Yqd(?!UpZIjvK3=nDfG)IbC>McK{NX&L2oI+dIZ+FIGL{yOm2 zwi4_2JiJuygoa{c5S5!&Qpc3C3M{KU%oZ<%%Y;j!Nh&AS&AsOh=Z=5sM3I2Tx4w6- ztS`MO<+(J`G-c_@M888V&OKeSIAD-mT7+6Oze=E6@1g+S`v`)DqJ?Qt=wD!GnO48s z_r%M7Y_rDJ1t(N>yJxNrO!h2o3yg7*6?O(d?bo}vftSx|tkmR_5pSWbfl6UdwsD@1 zaz%t8M@w+h5SyLc)v5TBP&nJN41Q|D)osq1hSy;%2@}eN4xbi7m`pkn_A>)*AhS#k z89!QV5K0EWB}(K>*gH@2Lg=Z zF+`kf=*^yt54D!!QCboK0s7gzHTkS$u=-lYG`6GcM8G`dkof&Sf3IZbWBjD~?@lY< z*H`ynM{?kMMpMyp=z>~GYcJT8&U0^(phihsu+e#~c}O|8^59g-G;6PYk!%0x{JHVc z5L5B$FZPpKN1QnbN9mRS0Y-T?eoML>S*peTs4ZQN}gj!FKQg$ zC?_0;ejpN|qjWfEZOxeuTmSfP|8GsU;W?0elCvF{qUeVObzNX0Bq2xBKv%c3C-8y} z9d=b0A`KJ|+GZo_Vo$DFMGu6Q@O@~zG{lIT0sUVs(NaV!?(N2;8d3njY*!R7{rPkz zP?>pW5ER#LBsM-(>VXB@0sLI4Rk-DILG1hz&6jbjOKZhY{5Xk*c~Pjm=|oK|hg( z#IUr_+Hd~cbN+wk3NP-`tFlFw0+LjCeQ@X3BcF{Yx{s7KhTEHCkW6PjYrFE=B`6FP zU0A>6RMB?q;qwxKQ@u)ywLG*0WD08rx`;O7H0QpQ4Bo{LMcADVmg-w%)(tt)s-8;H zN`Lm@yKO@t6m-MiiWWlr6D{8hl()!q6*2iy%?|M6JK!+6OvYLn-P(`y95ddkE8q|L zgO7KV(Or=@l?Uro>U<2^1CbHK5}jbTvq$K$(bPyu zDrj{dEMO2Y6AZaBDh)#ENfL11`K=qudrdl3NPefWNi+Y0^yjTuZ`1cXas*wjf?du) zTR`_&k}q&D$Tp_J;htiE=y?N2ZF?<#IA4=;~F|Ya4$kpV)G#9 zgJYl9TOIu(99M!_YSTi7<5HbA2EU_4TaI~PLw9xlCA@hY;rVPZEHM77kXJJrFbY^Y z;#H*P9W&qr{g%Yw%*Zw_Ha4DR30`u$qKQ7tQz!`H2W_(oG75?W&d90dsLN|He;1oB zuMlAeEqOCR;h8dLELJ1NEmF?%ek}^Ys3!pd`tl-S;@d_y@Vp`wcJ^fZHPgqz<2S*m zSSrJEu#oI@!8%mTrKwS1y{(rRk2h^u+7@$@ifD+o+W&QPPd^<&#{ZPVcdqJ*MIshW z;4o}Xi#;k|k-Vx{-!T=etQZoYHW!RY4(FFUYpknwQOL08gzj!OK;el7Zf7-D%+@j_ zL^p01FD>(5W{!O&gU$;8F;PB{wGwPgM=~IYG*G7Obv>X0Y+}bfU&dEd^G)~p4q93) zG7+DBu*5Mr8UbNCh^9V=2;6eh^MG9to6fn{-Tv;`-h8-|?MuUX8w?#{+f$F~c#So$ zhCD*NqsCFUG7sf3#rnEX1bSF-?%?)PJK<@XAJmXW5fs)*N+3oo?K<1}4E1!Sv_ns# zFD&$cZj~UPVO9kgN6tZld|TAVZjLF1?pKm}|L{7$nj&8V0N(SHH>&SJc8NB236oN@ z5o|-h%XkpZvGRD!o^S6T8i;d&Dy{4e-YJPOeaU?1&=vfF;C$(N}g(C#I`p zE?6AMX@BfPl38ch@vM)>PGfHrD=s7MJ0`|F*_yosv$zH7z?oZEaFCrV-XerMG}79F zN8DANclHQ6Mx`R!+H(oK#R>ag02}3euuW^{+AgH3dM82~!!KQUkY+zrEcaHzaD`7i zyuj%WN` zv&Dd@ZNmWGeF3CSitmcW!CJZS(8~z_fQ&>Mg)gF8R^&hVh)e3Mu46ebJETS1kifvO z#PO`+R9AKU(Nm6|dJQ8m9*o_r>0! zuzo@4YNAT?{s!DX_-JDsZYt%ceDlt$6hjrn{>=k)t2+^+zH6{3m^k?ipHyQVEc6brhKtoxtD-n(tVA*-cz0kNG&Nyzy0_?dH}6WK%XiMk==_WBt$ z=8QD`joMIbtL)He0T>9$Ea>U0Iqm~>fL87Erf<*zypqn-RPL(TV=)p%S5G-!DFpOk zsQtIRnAD7w>2Od8)0(Y^KXT>8M7|O|L(Os3LnmcS35~Dy=E>6OJ4Eh<=9x6Kvo0W0 z_lHo`AmjhB+vFqC?WHBUF`j~dZz`8*tNq0s- zseS4LT9F>5_U$mw1F*XTXTlX0zV(uIS&9zBr2~U(WVV}}n(Spii$;m1WzCL$4Ky}) z$(Y~Ypj@HOQyOJl7wr|&0H8Z59o0Ff_z=|BvO1mDV^v5Y+88COEkt7M3tudDR*a8b zGaKvQ7AWXtO-H0vu@$7UPbIA_Hb2+9>UZ6sra^Z6vIfkh_5VYr<)GOu;Td#9ZpZX` zo1V7b@n5&t_7@7`1%! zP_e%mvYa-h(zkwcI&#~s{fa5)chJ@`I;@O(NixkmYZXhC^CC9XrWX*WSr#TjEIHnW zAL%;D%nM%AC0qX-c_Y_r(=R3a6b0(~|e^SH35ngl}?*O7_4J@=Zg-gm~I z%^ZnZwA-?@cq`_y?zw`*T z-ZjL=4k-p^Hwl~g;u2(53?;BpXB+r2nqH(xV-UB44PZ{mY``yl0+}|4Q4+oCAUzZI z3S?_Ky&f3N-B?p3cu7MR^=FEywk){WzeXfAEGZieIy6qdJaxvG4c@_l4;0KW*U_dPPz{oD|%+ta!IyA{73)4SKu zxRvLts*IQB@V;Y z9=fzF9RKjm7B=FaTU-d*OGBOjv1ej#-;;GDVuVf(ZCasc^`xFvs$qWj-%4K;te;4A zdVd?9>EKC}TC^*>kegAA?Yp?B@yuHCAF_KTpVOtI618t7qTYte$~#>;(Pmu9M)0a= z6LJ$6qz1d-{=Gv;M&9{w@`aHLxsyE(s{du%T>R2c@PxO{W?^27ui3hEcH zW+!CS5&=`dRE!&F8;$eE&~wZ!O><&kNQ6WwQ1lbOk-X-5$*ig`HL^K}AB1@5?wa9; z4Hp)Z>bwu}X^d^Y;))oYbkIC7Cp-fN7BAFL4`6@|rT=gn!nO5Da=!$dz4y6MNpx-A zzPo3K)>>-kf-FO+x5H%mzD2Cair_>a^kxJ-$TZF~r}?>wWOKyuD3!3;%4w;d2mGhL zeMBL#d`*LYqtTJezXl4O-&{k#m=+T{VuzP111(V>!-LaJ>G#xpK!@!9L>6sQLRcdV zwd$lh37(w}n2L{V+x&UFWNy37=56HIJEjrzd#E`jtQj(?L(EdIULOp%xH-t_c-H~w zM40BNNeOhfib*swaE-acs8szEIDXe@Uq17(jL&EJmIavYOSOsIHEKo9_`AVV@!3eC zV^tARVI2~Aa1L?t*5`S6;_9D2iz$^Bu0W!y@$~#j&p*ZFT_@0QLi42sBomsQeyrmM zj8%7medJ7Do;>YU-Ec3b^_J!fZvga@jdqrAdY}(hIDfh|EBt>T;4Se+H}5>ZM{k!) z5B$G)+KhC?j#C;)@=#c5|GODq<5l-}O!5`|Kish+N97&zKo;g2Omx`Je%%!J-Iz^v z|94$N+V$s1@pbCP*Z}b>k|9O0mb;}P7t16gvj}~--l!U;(ZD+BG=}Q}L4;i!PY-~n z+!S`FX+-nmA-N-iJT3zG;YFS~$N{cbboBhG{?ZDNoxF|=RpvS)8H+fSua~~izK*Ra zVVVZT0B!vF;^H<^V~-!4swsld$yp+V3zv4s_=E4A)#zPpEV8j2?*`)eo}CAGM|cMU zRQ}HfEBOv)|<5YvjQ?$dh)YtvlW!1GZ_%zORpgfRp&j!Vd*s$h6t4 z^cw4c1M2Ao|3RsHeBf{iGN|?nvk`y0IBxy=i8nIZAnCUlil`*~rO}Xw-=2wZUFg(} z?N`rO{D70z1=La5gb@phxBSn?Te@0e&tZO}ZhwAU=>x^z2A&b4@imZ32dQqeFk%tu zRKVgK;!^mPFAdXzl@EdG*kF}bubm(9u#|@*fvJOp4t%w$g+4sR(@O%OSvN9Y=`;*& z&Oq4U8`cq)RvL!U$_CqEHQtC#F{osVgT;aWOT5aYL&eqC=6|HCCPTMDqR4#D&yCLq z5ufR^&XU{ppG1|+V7q51oeGc8<3 z5>>qg8*kK6I$80V6?T5)8&&tZU_^ou(tn2?Cc%19Pfzl)g%X)QJKP8BA5R8T^~a(1 zB1a;MYK6^bAsQ4;iqxFS{!??{BmWvTPu6Q+hOol>zToQ!6f~w$k-!b;QAtR&0tx;# zkvi2U5io{@T{3{C@-|zZ?LnSffrD-0IRGntu373x{h!U{(_;Jt#1mqY1gxOx55J~U z=?0z-03@^#qCE&{-EMGLlZVP*3Mj}T6VaZRdtwgA2uq98;OAmOlK76xMGJY5tfTog zy*(vU-`W^MhetwX_mLn+;f22QoOfv=hvTYAf%kclqCd$Vjx~$dE6C)ZOaM*z5%O%O zZm5{?P;6<$#RnoU+L3=bIl|iGKWV}A6)_1@i zrdU5B(_i+*1Hyp|y$vuBBu+cftOeN=B%WadK}8*icg^q%iES*bR=TT3;(a$*S#3~1 zt|MgtNm}SJR_EtT*$__D<}-bCDGr)R*iITE`pv*_kcNhMkwNYo$@7ru9Y#-rCfmk1 zNrMTF|3uM&%^B7simaR_))h(eHj&OfXt{=rSLD7_{+0g~8I_u(EB@J-9%w_A9qzl% zK@~vC3)2HIZMN&>?!ByG%Y9{t`pAN8Y70-C13UuFGo^=fG_?w{uzqn+-wI3%%uRY} zNT>?%0M>K|5lBW4^hs%mxh{ifIJE;E$R%WM*@8?~dQle=ORA8ks`VO~h$AZP44l_% zh)q;|BOlUKExmFlwc^nIAF?a(Eu?gSE5LJgSF@04oFfG+!N7ayEgHBM4>!SfajEf8M(pL zqtM_h5#&jx6^jv{86SvO|Ke9ax<)X*U_+wGG0e5fq)MeyuuK+ZYsnohFop&|zO8mY zBZ4=CeYeC4R}L{gu49T}QYR}PkTa;zs*-6HgUSf6)$w zg8BM4A0}Srf60pYO_2Pj0QtXNaNhraNB_h<`qp_Jes^aAH~fns_P=FQ_Mh&!tgdIk z{ZIm*&21^GHczS3o&Jn66v%!>S{A^QwQ-Bub5eeB!iZb8Ne?yJ;E(rDiKrMFB;ot+ z&D}4E?Lc15M2(fAKTRkkxR+$hDqU~JhK${GwJ{KHN|GzM+dqx-~vJ4p#L#+6c^HD$C~3rS4|*a{Spxwnhb)T-g~E1|kGZ7zrBb*Z*+jhu8S$H>B% z`@zv$=#UD`Or>a(&~#OH!MQt2mWL^cqR~){KnyE1Mw($rq>pBfL7Y-_b`XXKi`SHL z)?%7bi-@&Sq9m;%}kg}3r2ON7$lLw^qL zR-Ez|v-xk5n(zt>-Lk++Q2x;98KmHeWqW$nVZYn&Mdlr=>#a4(r#cIl-Rf;}s8TpK z+}G|+P71zbE5qH&H_}{5BxB2PA|W!Y*Ju8=efwR%g>BL(VzO>}VKs0ZD@V&_{}mnd z{)5Y6PUHoxtH7?Cfc^q3;6(q{*D;%Zk+V&W{i2i|LH$2^_<8Xa)Psex*U_w8##l;e z$Fh;p&GSqb(8xu9u!!`+6q3pBU1$i!`u83}yl{s})>wb-CF#f-WyvPbye6m^bTf5e zdTU$4dDrfXncEWwihx+6TA%U@`>qvZpFc+>3HP;@MXAT+L-f%{=B&otgOjc%9UU;) zZ79-^MeybyS$akOKEyt9JhNB*FpiaUAMs@yA&&_GOdj>f``greBs2VbzokFZ0vgQ_ z(yQe4lJ$0gL?r<=gWwB#9QGx9yYaeeVtUt;)Pr=t=Jv1)=f>D~c>~~#i^{E8%`Z5+ zlr5k6GKzba^VhOHWgWfSE{?!4$Ee5c?h%a<05nUi{Gdr{Q_mPh+{}3z98drSg7#v- zV40upT`S)dn?O;DK^+cd;*8_V*+%EuyxL%$VZgGtwg0Z!ip;i^w(Boo4|jw&GL<9q zF>ylmG(PS3@~$rN3|u!rihp{Nt)Q~*`}F2mQuf>Ab#w<=&Bm}>1c@7KwAE)h+2R+J zf(Q#DCR)mg?*TZQFi7jAWHX^bG~6s}-uTL%kU745G)<_3Q&F(O^vkrO3|FGjOXAfBjUvIADz_ZBh^a?nybUAI4VEZYs^ z_ZM7iH6f?(;#lf@EZ?7)RSw0Q*7~>bD8)YV8-o zRt%?lE3aI1?C57oj!gmtMH81Lnwk3&+xEW91od5Bnf<-}N=JZ(g9-{8r9WB}56tLh zdN0MP!+>vh!@h06ggqgN(<>0dr7y^63sDlU;?`MvdErKjU{M3ZLmrWn3tR(h=37qo z^XWcaJ}O2Y$O1P)o2tbV<>;wxY5;-ou&9rO2YhEoNXb+V$eLUiEqA0NKL)QZz@TLQ zqqZNhs;;Ps>e;fW-jX4pC~s_?Cx3q0+(@ee%Dp6=mL+0%LiPr4Z#w3ZyYHARgf6vO zmpYj1BAU7I;D_t8PTx5aVS37OABp(no~x%Mzxw9edR_TX?9D&@YqeOS_n$b>_zqRq zP-ZPrD%ir}sPG|9pqf`27uMBkBDDGS=;Q$qgR9_)Rcpmq8m=V{g;4^`D4|#KCtf8o_floA`vp>8AnEM#JKbp zTR^td#51I?eb$alF^DkmuF2U9s-zVon=*tpyAU)H_2?b)K0Uo4K>f=Pj8G&jxi~@) zpRgeYTw*I!g?VKiG?6q(fqqsaXIbfXPUE)+E-^Aq)K%B3^!kDK2s=m?!(Ec;4pKW@lGXWk&po{#jcS*#{Bx&q|6yep5t( zoRHrOcXP&s&SBnn!g1Gfm9Sd;IuDt__vb}1gGyCUNH0g?ya5zoOTuG^d$CeX<9#N^+0Y*rX+3yoO}iP3B`9Lbg%se& zP>z*iL*X#;W*x>y zVnS8g%f&>#s>Nu8QWn|6d2^ZH<*WRD986zRTR%l7t0zPBCY=Fuo@=5V^L72jg1Tf! zfNqkjNxPG@M>H-I0@s#4aT6>`^ndOoRg(sWcQVG8A4p@al(U!mhj-LRKxc(3!~DKG zy3;z}WQ}1V8%5Vm{CxhL+G2?Y7gFLf$<>I+h<|83v8Dy1E{%}4pE8myVFF^BxIUN` zXzq-#YWMYVic*UyK}F(7NmLbA2;PPMkRXFpzIBwUm+Sf!Iu~Ws_4P z`+I`upZ0f_pv)%YgjoZWWA)H_VkNr9zL`7#KwqppcH)IM?fw?kBj__>P5vjx4r5hz zTOf2Lzzc3r*92)5S7?&4-xt$~*Z(fcl-k%5?59Q!onVRNu}n0E$(1Ay^$hdalNfEO z*^31R=ym4)r9L6~S&YJk#JorrY5=G~fg(3Fj$T<^g6+s38CgCg+aXNyZStvllEm%# z_UIMzo4UPDw>2Fn0Tc!GCYsbq9YW)g8c=}$dQuOF#MPH~Ox!k$ zIAcRO%J0tHPlCx%hR7GDW!naU^44YFUU1M82iOgxHt-szqBYsr*HxcY-b4;fDZoga zZGTp7RH^bMZa(ODq=-yKr&PcEdmVTbTHsH}V(+&{$!~jr=+Dee)QH?3WlC8~Om>M~ zf-)4;bU6hPd_>HZ0Y(gy=;zG$IyWy)kp;Zd3Xnt*a+edd7Z@Ho0Z>k!#7HG1#YO?I zczx+x{fP=Kz&-M()xgBP1E7?m4(DApmJ!AByhTHzH8c)ylQ>g$A?q`EwUrqJoST16 z+40H|`miQzVaSn~t%{0EiK?>nt$8SjU(i>CQesF3roYs}f(1L4K+ixe;iX;+{(g(> z#V0~A@fVH&@o&sEl$&kDYXLw%jJP5Mf;FyBJx-B0usJ)<`$)dRFBI7V_)o8u zY`I8=F^5IZ@fHOM{Ej>a^$}U{;3bqkn$sYTKGc3m8D6?=Vk`v>--qN0V zii`r2rdLuMTOz=s;+J0R{-IbXj9EGcvT%dm!i|jPb$pwgi)+o~*#hfxawagV*>h^q zuvOLW4%>Wk-RUQjrZ?^xBkSB*yTPV)PCn-cuv3}D0zpftAiMa}X`GEUv-qQL0ErjR zH$x(cJa+G|Cp%X0^*2}*;GBr*U3-e=DMqgIN#U-rvl-e#;cT)7)mO3 zu?ye*+O+C7oSYAxY(D$T-@gs2sXr7V0pVck?#qGwG^YlN_LD%rg>kMsHwQnRJV1>E zW5}f~Jf@fk+tiJ=`vSd3u;NvbdZc1u$|&@pMkqfM3nnFX;|O}@s4u$fT(8WYR95r1 z9E12^_rxYR2YmGrriX!SCWPj72h@|)Pz6O7TcCwanp4}$kgl%PN5WJ?zBd+*=;m9$ zQxJjniJ3`~E7my0#KK3ODr9+;RSg&Nsnhc$RllCs0E20ZP4SY1&)1%;PpCcM$s( zu|W@oD1WSG6TlvhnmqnEf6DkszzGIn&|S$STP>!{dGw*Z7s7>w4Hfk@dhmHQ8A^yT ztkZ*cy4PXfusYX9o?~sa%V~J$3|+UD7MA3fd!zRGU7@_O-sk`onsN2x8FS0Z({Te; zJ~@&dLv!B+Cp4;(igEAK(jn2Bk>>Xd1cv6S!RvXWl$03*K_K`FlU*=T>XS>wuW^v6 zS+|qL^xtcnKv15W+^k1<2_-O;bpacSoKu@$lW&iw4R-@NxP=h0<>`i+35SW3Wj*12 zL|jh>dXo6WrOuMwh`SGXpqK4LtLwd!*gh+};HjP9c*v#uWh1zoT8a2psp7@L z>}?F(5z3B_=H4gLTl&d?=G}rvc)XRKkRx^p$7!En1hY}kTdWX&cgrJ`G79$xwgarm zB%sRGjOJ&@07|5pR~O}mo3B{{a4gcrhmsaB^_Bn^2&B?A&1Qlk6peo}hILWk5k^t7 zlJ>GV?xcizYb0UJ4@HX-ikznqNUiSPwU*sM{%H!4XgtO@fQ1tFH>?H)Z`^9RG-t89 zW_~S3@%`HQ$XoZk4)||WVvUPnGRTqU_0r`aHIDI7y!)!9AVX6(jN>p7g$`^&chs4_ zOm3_G{spUR=Pr*-2(~JKBp6g8MPc-Pt`eCGp-KRB&2t})SfUDq;>3y2o_cK7XJXar z5t75_O}RVj{#kH{rrx(#)PGEs@ruf-3vT^F%oGjLzWGaA9dseP@%lp!n=lxP@#g?9M zadHhYJ2{gJGmB@-p#G)ae`7y7DP-vH*^eFDoc8rph|M%o^tb&!f_sSx`xt7mB#`6M zEOnA;@$Ynt9eBU&=lTU26>BsKF95}iy=xtGz9*&dI^@O@0cP2Y>+N**4lRqZXnrx@ z@sIPC0EwKs9GJa)eb9vD7_vT0{hh*QhWk=0tc{TE5KxR`Hx6H9o_p5oQxsXf9Mb| zkrHU_OAvM8$E1nIn?s-;f+WiIq(UJz3bmZnlf<58hLwtIeaEz1vJj@ywp8R>+euw9 zBXoh@fwC=$NiOO(V&;ZBt%*v4Z+Y+gyPJR*k>n5|zYK*Xu7VOWl!zm-<_rGA zL<6}^w3X^{T(q}1XO|u2uU++enfS>Aj8v8|ii6WCDaP)I9ifk;u>i*%CJIS_sP*T{ zw`eG}T!9Y;sV^3xfEKi(A;=wSmK(=zoT>!kR{_!@*{AaU>df{ zNP#TejkYDF7+!1rr1@I%M;>`+zqsh!wtry3+Z}d3nDhjsam6mCPmn4cm!3$6))whv zP&T--+Upg2v|2Km>qkE2Y6~&hKmXP&k~QM5**{Y|c|$Re@dUY4!=6OyC=3owdA&^1 z?}RiZ{Ir(A5Ftc0Bay}vjbCpjMXL6m!&ToDJcYBOGdmgG62lGCYOEE#KH*mv@RyyQZC{_{u$R3&0kv;aQ|DcJ=bYMWA^{3cV?t3oO zGJTqWHlT&w98&x;33(=ogaH)EYqURb-9J=(5b7}zNJ65IRCh;c*#tk1O+3gqXViWz zrkS-1rDK9w2)wgnnGH8(#u@-IQ<%Q&%;8%ef2uonlNh_H2R-gu5(W)*12#RVTR<%A z^DhTl@Bl<;$p8rl{~(Cmv0pFE@pdgS+IhcRM^3kC8A?LgzJ2o3{ko zqdyxSyUZPzx$==dL7KKMqk`MQ2Ed>d{-)Vsawy*WOrVb|5-Oia4?iSSLCnj0-8HK` zatTSEltt%c@tPpHB`kf{ifX5r<+bS#f8EO{j} zdHipX5s?lx+3aKZxdrJp`D2aDAJf-{z-35Wa5qrfp0O0O2H)X5&I`jKFYz1x@%Gk+ zu4u{n8MsgrEJx&jq&xjfTHL=DlklI}T<;h!;5=(1AD3Tb+_v@bJl9Q!!!2k`k&D0o zuc2g^P$T~%!7L?_vn(2(-<{(BL@%dL1MkYYje8CAg#5pzztN?K7k$e97Gn@XiGeQ& zJ0Phn;bDY}F#khXA>jb{GXG_KGo)A{?y#9@3pVZNLPn4{9s@@ z!UkI&WZ)DBhCHGQpuNm3Si=!IlOab`&!{DT(_*}Lc;Y-!GfKex4j>ib^r`#fWkM_ z{p9Qvqs=-R*5q(F=!jZcCK-mkg68y8a~X|N<~_Z(*XLEe7QTyuTahLoqYB>5oU5Pm z@$;mJb0Z%$9R{jFFwV(ixPXNNZ#*(;(OVJ6O?m5(w%C$pm>K*i@G;fy^hCuIojjo7 z55epHaWdqD!lK-ynM-t1m(lE+tw3Zfy85XPQ3C2Khyat+TLnN`_jaWmSax;<-GbnR zC|`g3)J|QbppzX8OHEq^^^ zk-p6vPZh2IaMB|FRSP?_L7YnOvNg64ksW7-Rl`!~Cw@J;fnhlrtfz4V#!h5>hFguz zDEh9N2>d(MMEq$S>WhlAX0>UMlJJm$jay%!v|Sj4-pd^zaA-x0VAR8LXleqw2#e** zuRxYLUoit%mQs)$MTgc@eOo&-YENvv&_+QPj_DJQB* zpo}r-a>(pUwgpBrWjQg{5B-?(H3)&F63W*(MBaMYfrVwxz|eewOq}Az^Oi3@y>{O z$s9N2RlgQfOgbs>={N|jm&=Y&V;Vr-6)me}9yi88qDGnb++!PqnoJq#fIES&T2p#o zDUy3jDTR=P%YcjmlT~9W5(F<)fskenBw+eUpnF4n*aM2_$b78otM(CTNbSvtY z9S0!JSQkQBEOusrEDZhLgB+#$>)#mHIa7I&Y%Zdt@~o_6=Yb&td}K=iJOT;~2xF2BA9Q8Rt^FUPrPz2LJJO7X`+(Vd!8~oP>K&ixIY26fafZjE(|P<(h2qY1xT6`g&UC zhAYvyD51_-&8whuI~d9Sd9Kmrhlph+f{u>iR$aJT7_ATBcgQm=HYm?60_Qz}Am?nE9F-;aMV ze`Ijk)J~=MXr6Wv*9Gq8mifr-HkzEeD93w^gcN<7^Vf=Z8FIM?vL-G08e?@O@ruBL zvv-VNo37VdSgRx6~b?OITt z=sFbCG8A;WsNi#SU4TPJL8rZJOMy?v7-SNs-o4q})n((<)%$D_ckS46US9bi&W$FY z6YDer{r$D>j4?PZUdZ6}3u#;-p`oo7piI+~H1kzeRc9wmqzvQB^*ZhE zXM=8f(xr=aP1zp6eup3DEpn5}c$ zzMVTSj|b13J6G)KnjYVt@y^MIRa8_g*S*T%xO#7=(`lAm?1DuLW?a6;n*4*=g0s#k zGX{eim15+Jtyg{MoUWViRn`LMn60s_MYD^{rAwDuQeqCJSy$C=Vp%_kmHF^>FN=kr zWv$nqdTzQHuFZy?uNGTNOG`8G?t|~$oAsA>f75i}JlNZ|-u{jn`Z6a(FMoIK67=6Z z^W<0GZDie1`EZrO$g_*H`w-nzv-#hsVDkO#17O;@f+|ap%`?bH5k`#X2dm zYjPsij#d#2u40bR0D3L`fwgwMnBm*kgY2Ih66CtL_WW}5+i#Y;xgz(N{7Quli=SGZ zn=)hS%Q5I~)}F)xKkODbGR*=^-AEcg{wRyZ6|I?EbX-qQyW1gWaKQ{2yrAW7D_5Z-K&1IU&}jXdAJbIX59S6!bdMeXlFd;J@h!ipf=^ka3GysRfRJjDvHg!KW@Y5!Hr~rLl)3!x3*)&=s|O(`#f+mYpNgme}m{W z*!;zW)WY{Xg{tc<9Vt*$2?6T@5wnlKv9q%WtDn9laIi!R@gQM9aP%Olt1{6%+6hUn zr5*UI#B8-II$$!zA=~O5_Dx6-1*5u-eZhjeV>c@vXB#NnxCXPFpFG3~DZNO2_;f0B z^mm2ao1ox)0IEvY*g1TzORKPKFE7-hkO6g#1pOm`F*%d_bTfR#jN0l^8{SrE%)heO zo8Ro{K&5b6cW~lo^ZCWTZEW#ialj{tEK zZEbDUuDBJI+3@kBB2Y4`uXrMg;BCz-2$HQg&H%6WsRb*z?5p*dOV3i)wgn=!lcu`j z8doG3RTM4Rd}h!5soV=PA;y8(c2PXU8@)KG#t-sPgDoF0YL+LV>iS|W6O8)#QH|FW zS23gVPrp|9OIj#qIga@o<0yr<$6tcWSIYMitedCD&1?GM*$<`Rdsj(6xOqMtpr^|G zMzvV2h%w8u`^sF>&HzhYQXzy;?Ed(&Zw#QGJ4GN&DIt-RRSl&_H{eXUXw9m}>^DE8>=AM?%evzGRsQATxzRt(B ztmW$q0ye38)Ni=E)4kdKEg-QpA8}*fgXw1;t}$rhj*0U0{7K1FVda6N=eV0!d&!sB zpjhZ()_olKVyb-JI*o*#>>m)kYH9M^YIAek!{8e-HVWd9PHKz@9)ISw?c)ci@=gGg z=W@;IAw{{aTAX&w*v)~@Z-Ou7x?wk~f02{p_vw?q1!SJSFJAn%)DFb0+Zya39&KJO zE^{!UavFQEujyI(YUb6x_KNn?E64CVPg@<5lwa4-aB=pcHmTTSO;>;BZ;mRKdt%O# zTW}in{nBV{ZZJFs*Rhvus?jU-4b8_&?^vsonSKn*y(kIph)sO^BSSiPd3lSPP#GU9 zCbhR=xHR(=I#j)2*|JUv)A!CoeliY^V{4)hitNzS3)2jG_Uzdb+z+ehgYntMoA}jZ zEj`!neYQ8l)+z_D#6Q2KI4G{>s*8)IT^B&c(!E)Z4lO6Xd|t$21wzWxeEIU_*D2i) z$4Clkr++FBym8}B_cm@Go=w)BwLZF>knov%;=$aDXw@9p+iI&pHQ%r4e3y~7YSpUe zF-jlzVQca_Am9cY;Nw1B9Ix_tvy_xnzIYGEj!CSuD+C3Va+L1gy?Y(6QPMlV>njc* z2+kV8eA~ytgY$S7B%77tN?C4#KGrPc{{0h+r{q-%ZrZzd@8hCdOEzhnjDC_oYH;#o zbnGVm*UMXaU`dvW+!t{#$O4DUo5bCe*{Q?53=YP(A7)26J(|CIT(ihuvLOYEk3?fE z43oIU8;)NVI#n9x+5qQjQ4>x`u=&No)1vMP` z`0`pnKCq?Ge6l*|I>-yr4$@eT5+-V5iI6XyO);mt-aTF_pQp*j&Ti#1C*{!e4^q*gCCUm-f1wa!K1Jh+OHgRe35kB)}7zamCH8!rG?)VwtRslk)6E zLT{6xHInqsm7gJbWaY?Cw$FiKiaz(^4C*s8YRuU>XRLW^I{in{MtAAr{EBNo^XuKa zckj!oVuiAoAoSj+i^^;aX}+WNj>r3TZfJ*jZY|Qw{PTwlT*=gp1owkuZa zZB7=n(h1$HXd+*=IAVU+u3h)#mug=*Z|;*BXZ928M~RTe%b&0}d5QDN_CsHh8gt@i zw{Nnr+Y8Q9?bC-V-`w&vS-jE2zx;%E2|wG{SyIh5BZ}oRrelgK+qds}Ew5x({7Cj5 z0I6XCUFCI$9(%^gOuG2980zrc)Hi+`u|5lv>I^-8+iD^|CMPN9;a5m}q3`WKj@Pkx z(qs;XsNK22eg5qN&*3vFS#_L}gC4$)ELP)hh5@q|SGN4--lo!C*V}9y8{!qUuEu(Q zjD}cTeoRP)kwB3V-OqXLVc}JE>q|8p2md&d+@$tsQ(B+&sU(eGvahR69zj1|b|%?= z$UJ6mSKd_{mG<#n*_%(9Rcg*DY8^?#nZ949Me-;IzU_{veZO#qIJW<(bA4{!2a5+UFiizv(x566^gsm2;2qC8G`!>JQT#cCv)qQpq(jGoLw8;havT77qH~*eRQV#D7e`@A{NaB0`b#P6$%LDh!Pop zbX`kt-RXPY`2$*AKc13TP-uV%xp5cHu`C1>B)YH;hiX*C^0dDS#@Ro5V2}=0&tD?( z-l}Fxl0#^t*!9V*$ve>BSRGX{G>jyTrdR*x*rV=!84tbkt!J~#Kg!9@Zc`ke$-d?` zH65J2$DK$YM15s;ee{@r-M|Ww8f9!`$JFuyS031lh}n}p=n8yf9ESDl`UeNkh@9@! z3ea6|R4x#&|7K;f6;5%#Q>@@6kXx3d;JNmLe}|9=*Jk-cS->Z2dL*$w>YxqjvjM#3 zeW;jke{;tM3j@t~J*A|YaiFJtd~y6MCYEfpZvz|8X8Fa1=($p2TBMq9{uY}&+;R_# ztw^_A`q$$w3&SxP=~F$JBMH`c3wtkj>$+S^-ir;k^A}xnIUmC&5gXq;`}yg~l@~8; zVpTRGez=sg%(Aog;HfP<(^r^3SdnWU(a@=(Y6@twnjCp;0%uOeFKyJ5L{n0vOHpYu zl>KfJUlpoWk0qTmFoX3f5jVOPb?;5IR%!_eQ#SG_pOUz=;n1l3$+vC40+#7{U!||~$X(O+E`C3RLQ&yi`eLeRAchx&c1^n|@XXi>N#{T~JO!vK+qDaycg|sMQcO-;# zZbE6XE`t2n1LJP8HwA87d28&UZJ%MhMayp0A48&D-}-wy;$Xi@!J@93xmDbOuL}2s{#5PBtEw2USD0|J3YLvRoGc_Z4ekC;_Eqk4^eZ3S#nHqu zYsAD)y^EeX=ZZ1rvZ6;0@+)`xyC2|rsI0fti*NrfcCJmCj-m`Uvc?_9goHO*s9bR6jk-qe*m4>FLF;T|Y)!|#TWW>SIVLj|^K+~|sm zU31S=o!Zc=lYRA3`tny}n#Vu$sz1&>Ai8GF_J&v&rKXT{?wZi7*v8w(5XEh4znrIe zwO)RU9kBn|A;ogPu7nC{h+9Okaf6)v=AAM9moLvqwvI^T5n0!G_&2=;yN+&sRcPk! z;gLA%l90Pq@5n13CrOD`v=Ux^%>{lNmD@k5F&do>9^}KYh!yI^R&C?v0x^m?>o2ZIQXJwG?in z;~9K%Y!zGjeYn%oEL%Scw5t91?D_M=`R0@9G{E)w-s>vZIX0->l33^Qa}cHW!Z znHIdMzSt?T$L>rC-Z{(cjJE8Aj-EA@mFm9By-`I&)%`W=$4}wkG+sbW=A!J<#D|Z= zQw+cV`(iEnf&^;^6ExCoQ=G@Xf79~i5EAQfy{f{hgm=TjJTVG2=GSLSQ?{fS@J;+0 z7EAoj?-S-rd_sKrzw@`)I=_E=r8DZ#w~zlR-;TifNnOwBv@i$u za_&W;PzSDERnbME{u)4`cF!|2!cXugI-M!6Sf{6_uOTKg`NJ#D}-F!Y=qCKb=-z``TO40+wLgT(L>08 zyKIu$;cq>%yQZR~=lynekZB-cWlNE+efreDp?gGm*O&_^d&7^3-Pf>G$x%7~BwHU} zq~2yQ*Jcowymmn;RFHkoi&I6+$4+0J-yYrC2x!@or8Je!5Ae2-MqSaQvC?MGX3xBt zXR`S-xN^l@e)xjF3tu9biTKYoyLP^RAiML=KPr#@^UdX7EIU8>>);m#^~$RW4$opK0%@!P(P1D6IwO#PN> zroJu^1Wu!5)_(=rcn>p&8R^>I!p?nm58J9L``xY~{F6qDHZ;2?xRtE4VfF6O5p|6V z1u90qbA=t+0n`q1Y{e!YZeu+5^Hz!dV5z6kzb{nV)s8zwHA{^?Bi9@?Z(5V7qv&r~ zD$-^@iQj4L{9~Hs zDFdAZ91Y8pWBT>}#;-fq{JIF#=^XOrN(-rx-O%I(+HbK6Pp8hbEMd*P20iUwX-a0f z(-(h#@2G#lOIr6qp*Uae^+E;S7ROP^9+U4vCVtssICw0N4B5!DaS~L z+lFR7RxG2uP1`@T{V!r>`pbPOCb{(dfGq)@b2-A#SzTNAppvr=!2xJWVa|Sh;CPI3 z`dsm_|6@wU@5jg61GZ_yezdiI7j5`F8{69l<6Q_`)~ff|=V7OqjI-RpGiQw^4|QlO zCJ2>`y_6HouAvMEY~S|(Aa#|4eYs+5&PAYQ-DfoXlC|$(jJf{?o@ij4mR~`a3BYun zdAKfh`B;@7zIGSNHhX86p2$|={dc;r(1AM2vn_SAQRNr9sMk8)NMM9_@k!MHx0Htwdu*F|5-q3bYH{UIVUn(fS5IoV+-5Fe|bPWv^N+Zr$&->e#*S!t0wcN(!q(0_~ z5;JPx^6b>vSW{+|7w>1HHW;s5j}y0x=sLfq;lu9h$ySXy!IN8}J{-FmMyn;AS1??n zKWVw`H#Fi-f%WeC>KIHTy>-_zvJ974c6%yJ@b2>=CD_=>4*D*X+vS(9scQ(Ad-%Jk z#3xB*9+W4OJepWg$##d4&GnGkG?n|hUZ`5D6mOg48GiNeTC#I`Gq_W?`d#a|1-|Fk z{=>vHl6-kjQd(t^X1wGSESI)&2#aP$l0OzZ_5x#n;pkoKJ;rP!sJ!XrLUs+Q4*C0Sebd;d05Y~akl=oglx7_yT3JE3Dx3}g*l<2v!w|e`!NfEO1_2osD+!n*t z?g5)ibyLfY!Y#k2npjeNe?K~!K3CK)#B(()=2`*Wg|K1GQfz6i9WR+XG|npZD{#}_ zd#UE=ihsmMNrkhn$!A$SR8E*jG3S5voQY{U_ct4%wxUt^v+o`LA}c@Z5!@k~^iZ*D zt@{3Fn^0)=OSW4A=qqI`a8$6^>8y-tzk&7H8n5eQ%}55`1AHu8wRB;{b5c2GxK*Mb zcRsF$-SBPdYA-B=`?#MU-QJfc=Ha@oh6yP)StVJ%5e!HeyExBfMr zVN*EEJ~DIoj%AX_&~SlYS@)(A9*E0r2HaFE(yFK%4GOGEcFV$6H%<{aG{0li| z#zZGlbHa*bMBOv>VOxFCVpaX=qJHOiis=XC4;ti{2oaHQpNBUmE_n(aKe+}yF^Ni9 zNMhBko{4l~`)wL}P_oF0+!}F1n6cm)Db$IF=}gKgg)9A&8;Tk1--)XFE^=PMA+GmM zuaT<54zIY^ecA1*cIB<9h}cO7J%>8J>=9n6XWhq`EU)ZIU;XQg*N~45%jT$K*e%JS zg@Qpw7Oq>zKB)Dlr>G>+*G+1A_nsUIBKmH$L@>9ew#z)E^h7KvgjFf0HprWGB>K+3 z$%w7SX$WT0*NQ8MH2K3UGLy+Brh_*|%8xL`d^@ZyC#um{>$?)?kaw*DHuXvctL*H@ z&NPi!X`>{mSJuNg%Fkg%q5zFIFp{Frbs?<924@jpM>^YF9X7q3DK3mlDqC(4@VU{o zzFAY1(7r3kje&80{Yji&TAi1HoLQN+CHKgOXv+xcGdTJN$x|T!-*-&T`<{aPX~ypn z3oT!&Nf%hY)sStHaFRT?cB70k0Uui1LM>(ACtP-~=HWh}TcTLW*x@rk@pJmBWjOW&fkEE2j+y-dg5p;M*1QK3h!;MeB* z4gI#aC}LX!NQpFap2;oFTZh_WU*7Wkr1CQEh^a>#XQZi7tX6`IKJ;29cGavI&#no@ z?G5d5TrsqQCw3j_#4jyLbaIBA;t&JTTzSKNb*4MNnbZq~WCdqnWS92j$E&m5ueXtN zgtzNBGfeunPx!;Cw1s&$pP7wm{>I1eER7vU&Ha|102UzLTKjov_`|I`5B{3=Q;eAhwSJ@F(E{#iU2H_30+ZP1 z&1X?A!+EA(4g9E684~j=)R}xkyi>pAFbVEm=EVglH`#uy2sDz2!9nHV=Lx6|W&4bG z9$)zVMP7_WadWZ3D!U>>qaOht?0}te$~@)=O5EP)8=4l5um-Fv*FYn>Au6j*6ClWb zyQwW7y*-$UiF`8EkzqKrK9EA*1Po=vVyrBaH%LhpT?_4hA3ify7fB<}^%pmk zwR_z$$t#(N<7}**8Gxq3zi@M_T-;-_wV|blwoqX{;}Eg%Wj!LCR=&C1C{~_}3Y<^| zs9bs7;Imq^1->bCZ_OKhv)`P82GhC<`F?Lo&Sy6&imUfZ#~)kPv+d# zLH4>W>S|;0_C`xUdP=oxxbD7i+i_iEiRJ8^yf}epHmCUmQtitm)13Z^rg?^C&y~gq z>;J$!^41%?WYv~aPVGgX#P(u*qYei7#XL=<=-BZryo+}(&8q#B)Emkd?DDS-jhzYy-2Md|q(w>!@OQ4dVXinoX=BFu>|&pK_2svPrYShp zELHU>df_7{O{KE6z6*t&x?=m!@?MX)FIX6M|6R=KBFOVtG4^LYAe|b-w78A-iAIuRGg-PuYbbe*0%dRi&XXNeM`N$`9B*zocn9A+sxOA5OL(@ zQUV*(#T4%AxRE<|r|z^g`PGgTMp62N%Sj{dH@feXiZ0rjj%q~J&V>Fd&s+HRC91Y6 zbLQ(olOEOupJ^wjC1j}TzO+n*pRR=v-jKA z`*TJPo_?YJB)&G|QlYd;X-0<&*+~ID97g`Xl1r&w4r=`we#dJ{Tjt%PCSqOk(=!R( zI&p^m3eV8L3lhwLxsaR0TJ*FcKgu^dhly!&x_#_)8=sN-IQ z?S>slyD5V>K7&78Y+bMUs-oAaa@NGKi`)-tA zP$f4i8EsknENlObluyOf@d5Xi+%gwCqb$XHp_0sLPe_@zxc~t4@?#C-)PzU-9>&%2 z1e7%_KNTvS+^@X#vK?npZl7cC1avGfxdPgj`xo@PfIa4}!>kJHCyHO63tJUzL%;JM zXWz7}i^yH^PxE05j9Ez1Jz`O@6$WR1vQ zI4JMg%ceJDd%Dts%>M6{9EKKD*eoifJ~Ih>dBSNo&^pF{7%XG3uQ0SneJX)woN_LJ zzLRoEw*$6TS3LLFviT87Ss959-GD~3-Jf^3wJDsuQaL$ghK*is?*&h~sIz2-(>sw1xpt@&^XWHOarm4>W<0>|6O^-Uh z;^0m3``FpXNOfdJu!VpzFVVB>N7Fgh{QhDW^ZHW2u+AI486HKgP5WbvWNvZv`&lbm zE+wAhaeqfn^c@L1JMEXWpIZjstWIzD+5(0wc5`MQ3V$8BA6OOCZF7x4v1X~%WkQ-= ztQpoWy6&8QFoxOC_yo4!sd!vTP-E+<4JM1^zQ-FRf~4s(^ZnD5u~*s?{j~Y7F3~=o zkG}AYI|((=qGA~zJbSQPgZ;DT`Rubk?*BaS!Z$Dj?Z2D56`xvz!LR(y#C7j-v=3Wl zhA9BL4XDtN0%@y(AT<4_tz>2uncq%_#Et^bvYMiC2$MgwJ!c9Z?+90!dsWRUBRV{-xKuN-m-FvpW_C0u<`62U}mfI z*ujA9trgGNpuT4QCqeQ@P3|cX%2%3YGael^`{HJ17f`-Ur)_NM^(!T``{E_jwvM(O zrxhx=-ock`aiCh=m`GNY!v$HXf^Y? zv@gBdflJ!$U-~m~@|`TUQ<#s?`OUYc4SkIKoGm|g-DY3t_8#<|Ex?Q`g?MWpnL4Yu zaZN$wuuWG}(;F$iiuD@d<;s?VfDu^Q>?Bj)3kBM1dSB*r=^DlgDy|Paq@6esMJUo;4)ww3t++P~wYw<1kZ+0AQE!VH< z3*fPL7Dy$qC1&f7$Q zOVrWNH{yi}>nRq{bo0tw9KZG?r7aW+#xS@UGkjne6ovyv6CuqLO11%EwHR> zZl7U9#WAr1)NxlL%S1k4196dTVs~7SFU4LAn~!0)(UXrc3~!0x_$Wcm90rPBl6ytZ zN6pFxFErVeYn(UxOp>Fm=wG6~VQzt+-u)pExk|2x^?9lAx#`u`jXLAo)JpiJvt1Do z$=N6bz_8?!pI&3_=mn76O03lJ*F(F}(W`B>r>HzFGb!qlMV5LWF?_yy<+Z=!W8TK{w45F05V@3XXYMl|jj!c&=%fw6m3hs(6JRfoJ+tRsT=Sxdc6tMwyZw@cfpYl_dV^qiUD(KV!`_o0r zdNXx1k)JQ7uxsIRqrMp!J4GuVcRh2tjF3r=tP-^G3T(`L9N~1##<}|b({8rWW7Wq5 zbgr3}GvQ2WsqgEnbWaJQ*Zk^gZDs+=H7!K1`_=f$E4a9JDK!ZUuZ^d8`IV)wU#Rsm zE$h0aV6>n@d&?Us=k|E+UdlxUG%t3v&b{`{xweKg3ZL5uXZ@K*D9PgVzfi58@J~9+ z3k-LoP_}-Ej@iLOUkt%RyX=yk9GeYY6#YiFE%>KO`uqVdDG`cpWY7V&*4?Pq&8&M& zC{#P3{KPzvoyz!9;$k1O+e8k4(j2|@4V2Df2r3qd3@s8MC3u;k@e)SK!M25L3`J^< z(Y8kqt`R`ZKe}-lV`oI(VniidAr?oa+$m}XrWGC3?GMVP z-M|-DpT#zgJC_)V;Z-*ejP=|b`K9Jaxj)x8UL`kLi7^fGg3lv&Oq_kk6xo{`^1tDm z*xsxTYW)~<%OXtNXL+o%+}A6f6M4HkEYFT#5^{pAJvF>d_u`M+OR+xjZ4DX`m=-&3 zOtR`&E4%&b3p zXQ(Y(KgZ8b9a$C>N?fBOLtEQCV9S$J(cdpl#?#(1pQV-g2l%5<0 z@rN4|@xlT009Zcqc&zyZgKIeVmrMRPk0V$9T6NfOMUyb!Q;_P!wDKiln<6g)_q^7O z{g0dVGe5u=|MJ{{*UU}*>cb36|3hgFn@MlDm2sPVWxS*YBkS?p!qrWrRYi z8hrgXLrkz5)X5|~FvPU9STmVI&^iP^)t|D18m0+dUb_@}Mn|`Kfa#1IXdBAZ{$Y9y zl|1#T6vR)acP4H>_&PuniKDOP2M~LY(m*e1+4>9w;MW_YilK5}5^WpotoYRKzcuya zYt452J6mS?{Brzl$DS7p*7M!=lY z$127hhvw4&=o?AYwYhkN@p0i@3Tlqn!THbzhM~zSw|Kdfl<3=TzD@1+sN?o}-<%+u z@%Z4$O}9qDv!NNih<**bMT`QgmoC;KC&o03sK$LjYO|ZR0m`;Re>UM2iF`sqdrsGQ&>CT@=#SP+jpnq?Q>K4#&L>%+gC>;FdY2$AVa6QVPb-@|;o-V;VG{ZfwP) zBSz;EprvlC!BWPhgZSP*bGtmmH;c*7d!d}NJlD=QZ!E+=+5*@gKVLeN&cZD-rh%_J zU;g;EE3la~LAna2Hr#-EFT-O!X{XWnDpy!UUtFqc7C>L!7hC5(2^bBbxRS<7IBN}P`xPT zcL)-3!PIjVYs7-}Q?Ts66ekJxpGU*_jIlMTHb$GA+#+>^W}`o@PNqgCPLaw+o(Q{U z5Z0urZp>)=ccg5f;!2V>$Ig~eX~693Pr8vUBcIHzcAG1%P7`ywrdMI(OPFI$$nh5V z?fJ&0TLEXTq@j$tAi_0_f7RCJF#AHG^w@J{x7L6r(Yi7IrA!XiB!8-1pH-}BJ5-NE zW%BZKf)K(_@~#BHoy;j)V=m@Z5e88i*ujMZ$0aY613}+djTQK73DBi?I4N{Z*7e4n zrq`k+y7Fb<9G9F@>|0h@M6*+W!watZ&X?Gr->>ypM*hst2Z|pZDd9F&-wDd-F@jJW z;SO^B%i}ZlLZa`b%Dz{(?UX+QU?a_BRwl-u znvFOja@_gL-BPel`M!^lB7y5AS>0<>J*xkAzw&i+;59bWg>CD9 z>VNBQ6^B=#_n&3@!uAiQX9&&%8SJ@n5gC3wZ%@Ka_xA6&`*P$~VCt_4Nd^*zFZqM9eWv_;^Is8%6CS* z$L?_^MKpYDKlwYAfikqo4nKtbp-{<>LKiRfy;7;9!ycKipZ~(W)t$!fSx0uyxe!;bJN8_t%aTfO9J#K`*FN2!#{cj#rv^;O5Z;A z+?y$VxU94obtp}~^Hu2RzI}rD8|VuXQN@?C8p%@~0=|S6+dyJUjeE%Cs+r0;ZuvRr zp{wLd2G%hHiAY7dcPVhD6+TAm@PJsu+`3;`ZC>(y4JzyCXmXpj0;5xI?i2Y=gRqt| zB~VaXpRJaywbz>cW&OQgIkIpNA_Des!9Tlos<;KMPu+{OYh#svNM=s%zGWFM6B~Jt z3RJd9f9W^?3KNr+Z9_q{pjCt-efSb=RGvi z?*m8pW`TC;_ocmsik$@x^b{PtW?WNU_;DbIlfncL8c2H=p(Dv5+Q5NN^tR;*u;+gg zDMY8TrCMo^0I(?B&nevf7R*TrKccdKH+8 zb)abH41XMbXI|l7@3vZh6Tq;Y`u*dklS=w?C8nGWTl{X)uX0S z-}`M@4?kFh4b=h0k9+7>sPbvx4&&7#DQ6SCPlZeeF|lfaIHx@yP3WOaw#T&EERgt znXoZ|AB~Vr2b-v6if4NrV+8h++f|;RH)@KjQX=<|`bX;|!RBqG$o>vK+zx;viB+;1 z4=`OpQEl^Uk%6erg8;~Dq$_UzW_?uj>hA(gNKvf$-S`a$usL5C`=jpmKKRLfmJv%@ z?#FL?AIYCRfcX<3`Z)liUz}vmognhBAl3={1?X)TL)w6TH^_HeR(4Km3;}iKY`hO0 z8}U}AN@wyk0BlsW3*?u*q!q^Y#m_?8#piOHL9P*TV-t)iRYIrLbZ6FT-gXHoF`i)p zNS8pqa_}b9Nl&X^1gMP}cfUU#r%$!D^L1UK4?;=gQo-cJ!_ovi?#}|_eCmln-c`7( zwnJKMU6`5x#~`4s@1ZHD3z?R7mO65IgjULbTTEe!KpNW$)x`Vvqa7%YZjzwR)L|0< zZmgo4lI`sDMN=8I#Y@|nq_lW;p)!}f9xC08=L<^TUHLZbgf}F1Hty>wwOmTd`*GkT zs(;xg_p}TRe4=ep2!tk?mveW9fbVA6ynAm zm(o%SOZ9@oaUTLZpO?=|5)*tT-fWyRHI03xtx(cTdV14g1RpO&Wyky&$@~q9M^9z` zPR5H)cql*^)?0VH7lL*hQbzd(alA$2k5u zxgWr9CRT<_Xe@g7z0A0oU}`#{J+OIDdI%WqmY~6eGjqzkb(B!~QjCH-)Og zLus$CAFU0KALTVkyXlbqRGm7>yp3>`N1pNO{|c|8a{$rpI=*6dE zD<;1bAl>9wj+|RW)T-U&aQZR;yC()Ih#p(3-5CHh!J09+h_EpNca@)$1$NK8=#zp! z^d#9TK4Uip@Rl&LgG)-cWYxg+ds;%g?A z@CP~zpa3<{wBy9Hme;QA6V#hoDRdcDFxP%7<(l4=Z_|=ksKq0X^_Xr;#UKF9dfF8g z=u@}s)F-jVFLHd-lnLc-4OC_d!8>s5v>@(;i-ROZp2Ni(bFwF~bC}1+0~}f=Wlzo@ z9p*W3H}UTQfcZZ?em!8CI1m84#$e4&ILwFmeHjW>ogE~&t|3ZqDHQIG&n*h^lD+@F zDk+=Tx!vMSE0BOnWyb7#lWC+l|7EmYw2n)xqY=qkO+8m;`9WMj=!?}avmvtyh;v*+ zB)@`LkiJ$t>ms;qrc2Z!xNf_Q(^8%a$#l#dKs6n&-Mn*eZK8hI6D2|-et`9EEKYs( zV6WHfV$CE4`9gale<4`r+qv?o3&D@GommCxe(?Y(E;>OqEQ!RPmrBxy{pLoX!xj?A zP^}~Y3MBO-R*h##JK0uehr3^H>`GyvOnyeo_aZ2uGvBs!+#{%kZkd`~3~N}wHVHEp0CCVh89xZ9Be-z%ek z0!xTx)G!5&F}rC7d<+5_;tb*AfWs2SfqZvnXeV)%?p#Uhr^5P5d9^q4zXBQB5uQ*4 z-k12l_0KzDbIL4iL$l5Rb?=nsTd4xIN1MWs=Mwi@UqtBp82`pjjw06zNKf7-7au~> zWtOTBugn1>AuM+9oJ^vsO~|qaxd7aH_vX5tQ+G>!ZmR-5R39D@*`eHyx$Z)U?^k0k z5MHK$+4`2xeE`bVgs);e{$BOS?bAql%ns}+Z6+SSdsx8YGbTb4bqb|`)aG6uNmpfw zL(D5fgEY*mO;E2sbWB%e*61Cl>E2omQMumnvcgyC3NlI~;w;(>1# z3489Zg~G5@ZMH9UOe61KX*FOHhM;taJNA4W@K5&;pGKmZM%bw|2i!BbzXAuiWyBNQ zo27j=)}QAU)N)E!3gb$UIxRxMxj7P@|I#ELiltXH|E-#7CV^nGj0CSC=|Ld&(o#b} zgZAPG6J}qI-=zVnh(}{5-{GO?>%UP1(!!N!NI_zsPMo7siJ&!G$2 zY~pM8S|l5jSBB;^f4YBw_iAKg(=@;x8Hit4(>5gG$a_H0rDbgyxx1}50JMGeXjNiL zgRUrqw7uPwivS&c2?|@}4u{~mcTjkycETcpMk^}soc=>oAiAfVAg;FU*C`5$rCz5hJ*oFix+j;CiYorZVM|&*^d0o z@!tz~ zgz&C`HWD3z!=`Z}cdyw0F#7k8thKlub)}P8LjzHl3X-}nkZs)Q=rhevWfCEV`UbA-OFA>6ne zB0$n#hMbBA-Qp*0=eRni#q2vX8(I|^Wx~hR=S)s{N3MpVXA$we@84INZtK3#04KWt zxw`?Hxc_ zp}K-$%VE{0a|kVEl{(%S<-7dj>Hs1-8Ut#b09DQwUYD{Hzt-<%~Dq{S^f$TMBk zP0`o(p-<+H-G(N*?5?`b)qn31yTMh}usMH7MUMx{b)9cMb8Z7zQafmOH#ZifULRyS zid1xRuT4@Kq|(b8EL9Jh_o0b37hMnXt~pEvYI61TxVOl6zzarW+{;It4fx2uU1AXMkglfVItzUzAn zu(i9u%gky5$SDHGjZsGVDh_d=;i=q0pImKvqCRCCbm;B|kSCBg4#M5Kp0AA$kcL2N zPVn5x-Rlr%BgIIK-}UW*T!y_vVtJf5OK4rsI4V@)^r^l%MVL*ac zv&^xDUJZxKFfHH8{$`{m~&FyHc}%M z;^tPL?%iN6NEN*9L|kC=?mqjCylilZFO5E?lV%pLi`s&CiT)e3ljA9=h$q_!V2Wls zaq24lr;Goq{w@`bs8cL~|D1m~V-<7z%tES^pQST9+Irlf{qjV5MqEYTcwl`tVv4T)@#d%AG>D22a7chjN->zqB?E2 z51qt1sVjMtZH4uvF(SwHo7*n=!LHQ;{m))zB$U_9lMG=geA~!-$-%E)`Cc2P6S4fc zmV*sU_JXDJwqwsUStW59`x4nT`wzvk*v;yr0^b~BpL5SHs**=C$tI>~`SR2_xhws) zvWH_QiQGX!%?HK>Z5wJ(i6?iHsE(izi1|fK>IYQ)b#$z96BHB6yxHwJmwa~pHs<>WX}pGEN(@UBM&v3Qy`Fm1ej(T>U+*IyDv z*@cS8_N{c15E+0l1PY9sZ0zHM{fb7vRqD~Xt|{gLj!!u_C;(w{+=iv@9NL<)G}9Yu z$xLYaV6?W~Y{UWJKhE&)9U1Np`XU^{3{W9X4->{?jO}w9`~l?L3}NAJ2cCMhd4@Pm7B$P zXq9B(@cm&*`ZE`yame-9W@&E@9HOm(<(WMYOQhTmo@rCw{_we39pUgskn-+;GHq7x zPT8oavQOoqm#P0bdMmHt9Lbm`0bAB*><1EK!3nN7ON!;MMqOGAk}oJe6}EP69C7gx zWXmbv8*5~r)zDf}%DkLv?j5|;W=SYx^4_x*3!KxjJRPvD0_Bt<9P{N;gQG2n^`{VU z|MrOd&uwys;&``?X;~id6xm^bljS-1TVeLgQknN^kJ*4g^_x|K<-&f@jd@U^RR*af z9Ny);5!Z*%CtDz_r#Fz9JGLQ((Aj%d5lh1e)1ecHFOVip&7(k@7&BXSolu@yYE)I+ zMCLOJ|NWyrR?3~(Jq9sy^)UpK6l;YkjJ7ezF*1bnPh|!Qg((@Po?Qp+{4JutAh3N0 z)*`?#gbd)wp$~l5oISmw$u@YIUg(u8ZYLS<9nd+3mj9I+;=TW42^b?|br2A#b8F>^ z{+-6Z@rFHDmkvSH3DPsq@Bqx}{r&ro6DNhs$-2((4l}W;&)KBlG=u4&sz?NoZ%3Lf zwjF}XX7+;;)_r6~&lv&YxItho!vp>D)G`;CUy)u60R4C6K~UxyML)YsG{2 zjfb{%h&)VU7REIXLJ&V8N%UZU0ODo|M*ktk6H1~zc6$cd*45Uz_50ZTdEnDYUkG3P za(eeVfaSAHBRYk2@U7Aj<^f)U05=5xBp6x4lP_JFoMiKdYd16~h}}(L#_@6}AOiv? zKCqSLgXlE{H@c}&?H9Uy!iES&N21y@ju*ZSh%&ixNYEo?MB(9z_pC(Mt<+_@&Jvo~ z<67Zu>MGkM(Isp&uEIyB(-zEUPJC)p7>607=67yowNkf4L5XbG5S4E}lBxaH^oIXq zrPT+@=%?M^aA4B6F^5 zzo4t#&zl{)Y`JtEnAq!of@y|{BRqJnHbZ(K1Itwmm#Yf3(VYG=E*?y3%2ek~ZB58# zH!{3dYaQsE1>tJyT?o1%*XGoTq;A4ttmK`c>3(LUqr9Fm0|TD3BPF~MQE&aeVu^n4 zY=YJ)7&;8J#0AaSF0@Cf@cHSHF_Qy_zOALJ{c!;A!grT>pEfkJtg;|BuVM-RMmo|Yk_n&6jjaT{84DaO0jy@Ax zepE6F-bIq8{L#tZEXYSf{ht|=Y~78wCuTQTfrP7(AnT=N>|A113bw>eRaI5Xe2Z5* zgLDwaNp~vhJ>xmm&i4~CK3R}LvO{9Ah$}^<4R4!~KEZIAmCQ<$SO?>A$LB|G|N8>} z>*2n1@VOMYfh|k{mdkZ7dCfzc1nt))*-+KeuEVwklDRMmYfI!_b3W4V)fp7D4_5}W z3#1tUPH8}OGhnr3Fo--}w-zDLVr-=|N5u&jYNBUk{j z%e$V0OZJ_ERF#_@=oeF99{ptEcLqh0{3>bleeI_Lc?w*NIK&kZChm8~5*eg)DO*xQ zjM;WjHeW{o-1UGHO}dxhg9L8j%jo&Eeg}*I+(SmLI^fM;28}|!PHXGR%_@P?4ugf@ zil&F4vFCQ|;t6P%7eh!ydFWq0@i3f0nL?Tg;=1NN6AokGLb8;IWBeMj2nj;q<&`XB zZTo{TzpW}J9=$i{Io(rS^PO2YokQAl z4D&rezDWnIvD5q6AI7>fmRfuxjE4F8I`ehp^u{7&wS#%y3A7Z5+?pTd;YK{joaS3Jf zWbwDK?&CCThz&H^NOoGczrW_ggNdXfDG|@f)|0tkMxK<&Hv>U`y>M926B%zQiyW1< z7XwA3OtA>WjRreJwzhJiB7EEq>U(23Ol!C#EgWs^v|3;M?V~7IPHx}uM6zB}7)7cM zGIFKse(FCw%^89v;6ygP1k6pE0@7iJMY!1Z5;zX&h~OMj56XiR6~IHboEkng4CMog zJ(7y}QQiU@Ik7)u6G9k2DPfC}*%iEFED(?|q-JZ6Y}Z;L~w@ccyMH$ z`9dBVE{recD)c0CtH^Edq-r`9boLo8@uGi%w`Xk>b3_;5X4fygkU|}vDFA*&a^l%V zMO?N~dJyA*hJ_Hs`joa^0N$v8I!xJuPn!^TSpJXA`q$HB3MlJ8ic=q!cdC!O1kj9G zN+2t~#1;A&nNl_UfC)uA89;VDMNXbkksD;jyp)JQh3rg?6izx6m*!&Lv54sjW;e+E zb(Hxz1*HRq1z|^)X@u>Q+D!B!3?YqwtWeO=RJ`}A(&ImLmu(&m(?rsHBD;@Qxm_dM zMUtLufN-Q`4}>=n31?*>x!K_W(6Sqp7SiLLtI+kbfJy_tj)JvM0iI(E_3wKbfvv2_Dk9hGx1ja^LQ22r>_Z)Ahf6 z2HT4Hh-NZssKJe%P}9q=Q;MF>gRRPyQdbrD@>Xn*O;2pE*bu#9UU z*X6gEEUojW9|+#i6T<|8W)?DWLoz#t6`}@m3)%tHn}i7TSQ>EKr!SZ_Ql}Aj;De*! zP5bgg#8IHIp{p2&?9Hs=J|{g%aHuEDB`wo5inMpP!J7M%bUNrj`DX~s`cURUMWxM z>Ibx0aOZ@~rEUFl(r3>oGFZf)5xX{ufM~0)A+`elFmb9QDMCL+C9XT6N*0YvB zE?4)Uh&&nOb+}Y1sQW9PtDpB<9IsP)Hw@Ez`rp>t*Pd3{#UK6?sluN~^PgffP_yM^ zC&37J)7+I5ds(9l6@iz_F=xk9_9_UmJws+3e2n&=<9z24Ohn@B&X|i|Q?4jnna%u^ zmrIr3y+BoYjV`ar5zxPaiN-pmIB8`n1ls8tVtUZ*+sNRr0PAIynoSq~m3S^0j6*a~ zUh!b>0q?msMfvRZ)Bv1`x{|LCkqU(5mG2NTmR{mk?y;CGguoX<-VmmoaR1_6$i-_D zYaYZQa&(dEIQy*YV^v%valrQcj!q5?-VnPdN2id>N%K^&kiHS7o|7S2jTXz?=5Sca zTTc?noNWv*Nx`l@hed=bjjY`=*>6)*e5|8)ck6DGr&EOeaMN)-yL>{GF3sFDPV?DQ z)n;}^==aVbKV4@F&2)A|hX12-*0%~hL9-<0C?u)jQnx152fC^IzGU6_cq)|=0(-BW zkL7eV;n`(*Y#PSOCzzZTqs)gyxaSwpOb!;uRr+IXX@_H_auCt9P=R!@@xsj+vee=V zoJRw??9fwJ5#FMJsjc3Doh&A(S*Q8?Mh`Jqt38bZAAmW&xi~!3 zELvNnzOb=xw9(USk)g}GzGR)qq1giMlo5F&d^O>3B9lWnP6DPU2Mo?ZCV!d^Ws$hh zHj-gN{vH{l;##CkZWcZua$KG1ti2eh#?G^H3j{Z$<-cZ9eRO~S*s; zpWrglI}^~uU(Q2d-((9-Y~jU3yv-t+L!fTOlYBE@)3n3py`Daz#~GqMGUpD>D1KIF z_!47S6N`OP2go}yL!oU(&Jp@dNsaaki(z?y?g*Ol`PGY{7RoQ-M}w~S-V3?juL8uA zfXsQ4)VB~xpHdwuc2u2xgAYXL$YV@KkYXuI?;(lVZ;6SeOe2Cplm3G1$7H@gF|A6u z)7dc^3{ElGF*$}-7@`NBH7uJ%&HiO6(w_N}W&DBf9%@<)Ke49rtxg)6eMHC9)HHbG zGw+qTdq^`SUnJ&Kln8Clx3VB8ZNOJkJht2UjHF~vf*RyGc z`q~G9Zw6T)A^@3_Ci&G=&WbXUZ&sh;nmd^g`9OQrCzTT5gd^MNfACruDqoqKd^ddP zPuk`V`Y`e5Nyfel`N0wX!wgI;O-dg7D{tEmFT>x-IH~-2>~fKb-WiO*tLgulJ@bAH z7ome>bPdM-bKf3S+6G5K(m4OTQJP92`R}uDpLZ9QdM9S8M-oVUU+0R9opW`E49iZN zbp!lMZ~>coD7quHj|Qel6BGN+GcrOHH{aDhc!XH{45+pwx$Ig<8qVn90p`dV;_G(3 z1UOpihQ&uoPrZND4q*U&%QqZXS78_|%dz6-MPLhLc(<{jbA_ws!UqyLb8W0UwX6+l zN^4h;R7F15WZ~(e8Dyd(?l2hmu*r7VwJrN0{TfkQYrIz2lW|0GFXjl_yACn6oMpOb zs4e;L$Uv{41CsR&c53^c!!tuBp8LaS@6RCH7^dD!1NW4vOOXL@iy&rHX5zEGkhwnq z3B`fa^2Wf#wPt`x0lfeYc%xE)-s9NpF`F)Rf!ru2%CjAMjg02|-08_wi+rGvdO(9q z?%bn>Ho}=J+sremaf;Lr7!GdvW+bETm*nkKpf#StDMu|`0IcmJLS#<41Q{N#^$JME zZd57-*JHRn^;pJc4n3eFSxzI8>NpmsKdtiyeb-+uU=Ou`)pD^->c-+!M$!Z+;(>W% z4OX=fhi7~ub|9tv#_U4RQP<0-SgNB~Vxpc(%D7(UD>9tjMjj-GBgIm6<)6q;;;W4MjsjD-w z%9E2cSdRbj?a4uI{a?tmF{smD-4+Q*Uk3&!B7FRTZ*bLTdw8KXuiCSE`SH{WACr_v zTEX>fZ|f}c!TE}&K>T!4nTPVUo4wf5)N(6DUrTQU;?(p)mQ(%UhYtAj@sEv4)K)zK z5TMr_Iptb(88T1Ch$*W)2B+to|ud$clZ>CkDc8#)ZWZ@1AlPRzXF_TG(p?M42tU{@;d<38t~t%qW}^UF-V0wCmhaoj*GXwbeJKo_CXNG7jed~@*88Xj{6qI$^AhFBHddPmnwcJ%_9sv zCxf?j&^{-(J#ZI;+X8vw0+vvB=flt2`yczlGjPVL94w!A!hB?=+sE6}XLf)+YN8Z| z>Uhm<m!=uP~5g zN7(3$`BrAos{p^(u=5L!fBtM-0)#pzlARi&&QE~=X+KNc#d#aXahEAqEdKd3VDJB9^?Zgsz#*w!B~_B z!F)h5=IJ=Vsai&FtDWT(*B88aZ@W&B{$dTx9uGw7fn0h92oQo!CF3!65H?0~{#TI% zFcl&`_7}WBtW8&DkvG54S>pP2L)RTiN-)u+OQ8?vkqVj z3CJTtw4{Cqmn_=<87bIn^zSbPMBz``nzfmNYj25#rYgZSlByQ2jtuBwLAQ1PVm1#Q z_8PEBbtKXYEz6#z$MOx*2wN2%uEYOlH(cD2pCA_80h4X*zp8>dkkoqq=#n8kk77s5 zvD^8yVBTN67rB$!x|j>SPd{B4A@&f~t1k}Of9xfatZ4Arncz_X`q2UG$wV3lkC4(t z&^$|jK0LsIKhg_KhzzLX??GjjN1lG+hJ}fe_P>srJa@rN?#OkQ8@o?2?8Lf6U?@f0 zdEkp54eo&^JizD{0icI(_pNmCInZ$PE^dL6pX>ex6oZMOB^_x+NYF5P^Zi*IKFDd& z;Z+7j31LcF%J{f2ls~8>8jTk|Uk|qeVWr$p9s>6K9jU9=H#XfM^R4NSS7Oc5KMc7I zMxXyF?S;nmDereKt=zqDtRMQ4KOs-hkrkv4ZAA4Ot&7w?d{(QeBPF^C85>z}OPfM+ zd<ii9TLV7X9thW@YizB??cbKQ58Y+FP{V^mNuQBjeiNC`?a z7L;b8DGJz7h9U}x(j^*e5F5QJ9Rw_NY4!pFQbdpzX#xUL1co;E_pZ@o-|X!BoOABU zbIP;%%SV`*HEVt4eSht{?Hpk5ku@p*^u(Zh6^CAK5pwh7VFAfA_&$He%nnk{9r|vD#2#=vX?Bp8>iX4UJUEX)yim%SeRc8Xg7U4^ z#@U+eblAvW9`>6v2PV}luTHYTYbqflqH}i7$@>)nVcI4xu=eCye(e8&rJ7fi;U4?8 z`P`)b-z8Urh8O!mI5q<2?oLJ0x8N$hltZVb6p&Xe)np^jW4winmEl zO1wv>C9&$Tr*a!4#5#^p)9K{6juazK#LF<*T(91hc3NT8PcYRMmVGP(#H7K=Fi14k zi}a~1MpVKOp!yP^DBd*BsZSnj!n1*CZN0!>6{Q{IZ;z+Ziy6znU?ZS)ZuaQ9V+1a@ zLaoc`gCIeF-zX+R&nE~C3{4%8brqK{bqrE25)_7$=A(##Hcxn4WqYrB@nOvPxtG8= z-%bM27Kx%e915-1_cVL$r&8#YBO2IcQ7Qmp zM=U4=L1#cIVDsKMLBmIqB=uUDp4OEhzI4z+=s>epg15Tjl&yAFgppCP%s8~90NZ`> zHli*|j=}coge7r*=ccIb7Fpw@`&exnnL#2TBf0td=^NbRV(1fXO@~SRw{{TZ$pUf| ze(J2vsWV^%2C6z`vERQ)Wfwd4eeg^CVuO=ep#(qFZ3Fj;kMGZ3PuaOL=}`nX(PJTt z_0phlT>j3T`=>JGHp}-d&)M*gF0Q`_(*IxmL7`d#Q_(mT6~|{htYKD>ty8C@kV9d^ zf543RxrQ?n)McC&{}Uo8X>s{$sG$GdS=Jg{W>;T(mFmX`QC08Z``9SN;xxBbr^eoQyZ$2 zdrvZnz<}*FLQU9VA;o+Oyyh+D=`oM>(+U#^ASZX4!a~pc`p2-Kt{O@uI}^td!2@wC z$bH5}bEPSj`SC|mpW8SwCRvuDq`Q->e6^j9ms^N^C7X#YM8)CKibc4w*KAP_s=(&A z0{P{lk!p3LsEl}|D$DQ(_aW{M+-An#vaHevL?v-}6rjcIIAgZ%+g-}+FAueT7neV= z!oHUs{KJuVn(e=!4}QJMrT6_ShoW*C3r+pvtmT`xXKUSKtWKv2sx4CwnEf1K*I;>Z zSY;9`TJlZ})xxLeALgnch-kORtzi_(M=B2lSQV2`vIE1U7d0{ClUZZ`6*D|u2IRVo z^M=RP?%8)|8e04D!cghMQr1$aWoyGxj+AY;?{0X`$$+k7W%A0uQ;6%J4$5E6Q;0{G z7=*}?;=N{M>7z88Ne(0T=KQg)*xUClbJbejxM~XS9sh6Cbx*9)Q4et& zb;y7WuoxkJah8E-K_VD75WE?iSR*)b=S3BxB0n?pyc03=1bo#@9gjAa zyhBz{_f_pxOO-IQTi_&Ce|VZdZ$X(Px%n^!Vb&{4EC!uq)YkI>ca)8wkd{E(g6DZ^ zw{j5R(7?)ZV|sjuG;t~G1$xH1!{7A$RyFy{Dc0%YUK3Az?ZM0$6H)X z{~Ig+EhAx&+Q2-K&%naJvswHn@Wh|Ji^5kOP>!snrmmQZ{3c~ISfCyCMPwx-uz~3u$yJNC z$HiaH%5ZI>F!RM^Jab222YFUb1*8Y)qhrP-sx8KnS@&qSdU zOt@}<=mq@vV^zRIh(M051i+1~Cz%sUcVZdW1_5Z-`${beH#`#j{Q|URE)Iiuj+I4m zOy|IQx!^DtNqx4LjH^K?Ty9=}%M~+J*Dn=}ZX`u8Ba!&V-+hko$%qZn#??SZd=nS^ zVq$swv3dR%Pm7S_C+2cO9lq+gAbw|~AXIA}Ei_`vw>~!xn7uq1I|qN3BlAZ^ievl9 zH%#U;hiRo(peK#OO;Iw)<{~F5_Gmg#=4BGd75V!YkX;PD52R|VBM6HE6lfX)a*Z3H0J4J=>UHtH*4%ur z+$`M|VIRLEc<}NAgUo-c-fjKW_z-x&7Wf#n5DrjQ)1EkpJvsJN8wCY0?sS3?oHBVP7u(l$GdqZ~ z9E7N}ge--@u&dKX*9*CEWX^D-R199&g5MBqYxcg3xr!4_M!%9EBZ{cFHnT-DR@uG3 z#3b;^`Bf!?-! zl{GBQ@tM3~Dszxun76g&W`^E(N;^ZAX0lz}mF1F3fG>eb9AL?W<&FRkFYw7w1l(}6 z;PZZ5G*{+$T@4nL)2_U$Fypunp~WgM+RQ93ERTZF7~VBp;p->49%+$0vY2cSgog0- zQ<~d6B$zO+7NR{7qrtpD#zVM+s5K_{8h^DrVHZpwk1@>JuhD~%0RXEvt{oxZoyA)6 z)z^r2R;x?*pOg3v*t8sJaMX`w?KX|cvTX=N9Z zin#!0n^I^@P8;*86Ek7=Atuio-fOa_+rdx3%-nM`7Dr-;s4&jNvkNY6B#r%)N>o%s zb~O&o5paS;T{rle`6m7bb;9xcNk2o+JqhX^eQnP%TiqxU8XhTWGE?*X@$Y` zcC%$G<$aj(d*a(!X#mf%u7@z0DlEYuVcr}5L)yoGt9t)mEfb#lpBz;X`cE5-l{RFi zBzl)5Nu;5p_JWP@q)(DP*pQls9*^ux-#&_gDgKeSMirGNZ;~h_o|2prI}Dr1tVICm z*OFNbMlQ<`GJRn45B}y;4>Q{Slk-p47ua zomE1BSmNqq9lO%=p>Ot^qSRX?V}-n=Of^}T0eqhlM4eog1m2f~l(>dQ-bYm!T59-F zc@=~y%m#g_g|iDDP409<&R9bmvhu2{cl(5DUYW(gH&9;2*l2Kz8sAdM=fR0!lefsLg^WExd5GVYhtf~dpg_%nw?G=o zxjU(|L1d>_=c9r09=|>Q5Zac!@>f^0s_>bHrmpE<%(G|OVRO`9+pfZV5B~{xWedb? zg+gH|x0I4#UF|<4LPXGl@dJA$y{a8{-if+bPhg7LPYl`^1L>? zd~fHe+#!&53?a`peuLf@On+P&9&-7Q4N9c&cu;oYwUI{q635=%^JDWN!kHdyYxVM9 z!1WxshRxiz`%S2WBa>_LfC4kjgQYZHc9GId0#uwivC|OC9gFBJx@W zc4_%1kcwDTZxjerTkQw+a=5gjC)0l<9E153xM9McK3Mx8#mMkTSp#|3ozhWjgac}nLksB4#?UCx z>qeWg`OsGIGo207a!}aWjwCbt!t#F1ecj%DBFfIkJzA(SY2@LTpg0;SZbBQq^~*KG zWYwmk+c4xHW&fkzeCgGNQowRbAH{OWK<`GA{x_ayJ##LLpwpsglfq!7o=-S2!%B=5 z+FCoY+}Qkr%v#Tjvl%4(&7jA(PCp&KAlG`|f8U`ote%u$e&5u=3j)jp_!sF%x8aZM z@1M0-e;|ne=lw}h}< zr%v;6y8`bPeglm2i{pcWa*7Emp~F#V^O~DWDtxBWLdCaaHG?zQ7bBdOT+I1YrlV5A){8aUB(q|5JT#nbvl1UmZ&nmE8mu9}~ z+~3o1?2beC>I%h|aXD9i=T!S12y-^J6bw3A?`IvRXXwSAIrDX1XfQ zpJJ4XCUY*jH?i}l>M+Ye(6GLoMyG4Iag0K@e8E3lXS`5!hSI==Bj4V{;NT2`yDun@ zSffjz=D|0@?fc;;5g2*P?8O8Gi=Vx}S;M8`AqgQU8IsOhh@z8%o{X4$sW1K>*c4qK zO@^zcw*aDzp^xsQf+E`mkyd5fMaS*rC!^ZdA*QQ5A*xZRUyn9$xL^5@`FW2X#@)G;COxqG~&&?_PR%uC({4_-RzQpNW<%WPD?dQT_A07Q=;|Mn2Bs%TyGUm|V#a-5yk z$^Qml{y%GrVP@r@Z0!G1hhZfBh4)438BnnR!o8DTh{c5yS4RP$^8g4?uO;5Pl$l>< z55DU7o3FH~dL@!KC{iH^SpGFqk0g;}4V{l9WlFaI`jdFa{{1CCK=o0~1R^1&vzIJf zWaMFk+F>G}iTX+Sz1b?fPAioWIuuSH0eiqyw`srb5Pi#!wjmTQxmJ2i?H+iCsIPCVY5%9=j;`GwL=(8^n|Ep zo${F)M6fz%R8R{q01>yVNU@kE|CJl@0`jFYQ`AQA-WJc0uu4fwWn-Y#*Re)M*=L_E_jb3qIZk zQK%4w5aQ6{9ZQtsSaqq>S;HaIqXFmIMmzO)ctw3z8Yz=?U3)9@3`5z|(h9Ht#{4B0 zNNIJJ#gzvt^RP%QUzAS_aZT)@OBx78FTtMiQ-uQ)vCEVzxG>c$%YV81(A@L)HOEhx z>uXH`ie5@@A0i9z3K@^dKF1u!_}k-}Ba_UE3U)MS$hKR19u`9?6iA6ry?C?{MCH(- zk;Kof=TUGht?h9jInF;ZKIdIW@;L0YhXzTENYxS}V5EF9^1DkQBh%wEp(3-KCtPKH zqxxB7^KaZm=0`9^3?5G}yo`yPtUMz#j9JTUsXo@jrwvWn*-)ioT&E5nD)|NoVivV>ko;1~RMGBA4u>wF&p> zDF>h2dH7&tZ&L*m`tweV_vRNp11h5V_mZxS@gIr#34%3k*e-S%()owPgORe&4pYIP zrXSHo8gvkXIYw_Q0Il|%TYi&Ay)>~nxRP^$s{Q$5~9syLf|v2sP+Jnw~77JH=W`?aX-8xzR;J`}`=^ z)QJ03Tq~|~TUM0g@WrW+bcrlE%awZ4IUsrO!xcJd^Sli>45IEA=0qMJ=9}`ve2;mn zQ(g}}@%t6|vG#_P%hW*4C-~txGQF{p=oH<3M*S2EErS+C8jXf68dYWTTt(P;q;AHAHl$7%t=~^pW};io zQTV%Nlaht(+Wfc5SiS~|P{@~n)%1jo@Nu?Na>Cv(`LSn0GS%wLp|$)7O+1ryq%Nfj z@n^QV^VS?5)?T+QeC`wK7eWZMr?;Dcs)-1lAO_tVv55E2`xA*r?j&B~i#&vT9q8=1> z^t5Q=*w8tY(YD_C=f|e#E#iG~O^unf!)`W#az%_>@#jHUX4}-jIKTZi8oOo=GWHV! zvuP%=yV4qs5!ZJfD$ZLs&`-{!+Z&8S3r8eskKw-F9KhU(yQzcFp=Gqeb^%~Ca0mon zma`YrEs;)7T}J4=!U#p%dX^!YL9CGYMg5x~cD)jB4?2#?{RPSY343Ysig+&-E$5j<)1aj?VYick2aDsFj&P3%1O^V@sD80s}K#~OKN;j;8K5!m-4bD-K0 z%&*0;+|x#Iu!(mh5%DY1(+hw6ohSS9TRX*ATIIy}a;?j-6`n_(5ohB#EIJox0Vj1g zJWbJ`QtOEi_2N2JW8!m;d~0ZS{X8rsuLs=r4s#PEaXiYd?9FROxp%U;`}Ak!B;uuN z*_{xC?@$=Q8OsUV$$Qt92@g`7_>8t`ZPRluQ&8aJ4@=@rEFTU8gzksfW+r-`9l^&S zNYdSLpf1{m1k8r&o_71uUldwjZRxpV${}XRHP&0 z+Jp1x-m*8S3Xf4gHBKPZr!XL>R*&VM0BT*y&06Ge?ikr7QfKWY1Ab2kLIcHe<_UNr8IZD8gVCfU)}S)QkDAw3GJtGt{HgVc!R>ykuWQbB=2+fY?Yyk!tSNqBj-w**CzSKjJ|nQ zFI)LA|0ZITMt;p(zbyHEx=_xABkH3cMia&sC9Ti9{E-QJ$TZuO0!Ump-DJ~nNJ0vC z)?@1}R{hqk+#K;)@jG1+4&FTa;g#be*BQ-yox80PW0Lq^nCK*3K9^$mswCU-ZOCC{ z1~#^td91?u%L;~z%{t%%@=u=qCSGpdD!XV~SGMt2U$0!|$oE9VWJOvPap)({auc)e zeSJIDyq=Z4{>Fu~8AT7aEbA4O{YAX%$1Qg|CW$ZF$UnSf3E%apk3GL$FQXwHCbae8 z^oVJ%iqB5pGFWDcpLnvemD=cIuRs9d{2pHO=)s zn0QdBZHJPqTq?WYU$A)m@+-OBU06jsTaF6Pd1Rt_rM2Z#jcr)U zftI(GJ{IyqtL)73>OI0;Y%M#icKt2%uB_a8tJFOQ>IdKosA)gGprX2CI5;4_?@X8J z0(T|rn#}2Z{Wv$&u~gpT(f!_xoWLWu^ehj}^pWc0RO9iR@|D5mcJG%xckfH=id$Ia z?kZ6>j@!u5@Dd7Ld9dJGC%AM&xny?Lc3GJNT>Of$ejFvqLaEe zCL6!2f;}tNG|yWP$16!Rj~HZ~%X=r5o4ja6v*8>QtZyNv_1z1T?g|V5yQ_S1AE!F$ zlKb(kMRKfMr$>fsls0Gb^%)z}_;#)HE~0fmVv>@Tao>ju9f@6Ow<@s!Ie3jhp=Xf{ zb&Q7p%R+@6`XNS5cV!YC`uk5qVeqnWQn`2>|@X2n6xz;fm z+rYLpA3^IHKqk{+-vK#}Zgw^t_&&YC#wgW|$~t1q-$S$p-cbt2)3|g)tLB{;Aa9*r z=iu?$G~WAKb2`KrNm+g1Mh)~`jHYCH#IfvBHWYR}x)@w+ZVMHCb%BNOtVNgK#&R8K zU2ogIFXYlM^{dLT${4wz3NQ^!Ki>7ub;kIsy_=<#;t%`xA7ww=p8jfVRJwV#inHhz z`H9C)e7zmUhKf^kG~&{Lg2r(RbD8+pvgu$z4@=lBuWHCC-Y3y{I-bh&%sjru^ zO}x){E5P79{k%yrNOq5Em1%n&AD3cXYdk42 z#j?WYzU|7__e4~bI&T|Sfn4i%>^YMqn~JO zlwD~4QQxa8=JgqipS`77R+uXCWr)e&RSh|;X-Vnns;?4urQb2kc6N|GSP*D)^{VmF zP20l__ci7Fm0_*T%jpDnXtVE1z*7vrgsN+FaIcZxDe9$Twr`YG8|>4<35g#xL&DVt zo3(wHUthS66`Fd7WB5)(ZmpI@5=yPR85k%ULU#c;$KZ^x?dLX8PZMWwdS4vUX#6h#3p0>4PI&Z!Fk{?`){___2p)j_@qrz zZ59MCiHzdN=?2@1u03)zyw=$1aOL)#W3sW~0WbY!<1{nLAGB!e2&T6#WvTmZ)V8#^ zI3^}iTd0UY+DAzkiK%n*xMhL9IfmYn$h?niR2NO&>45F=#iKQH-+{BRHs~^#UWtP) zm#PE^e{`tb#tG@#f6-)OMaiQ2lAEr1$B!cGWzn|>hyBkDE_~bL7|^KP0A-w1|75?L zo97mG_LFn#a`|L~lc!dzejRh;+4P)+g2izz7(3O$n?x<>(H;o_$FTBcmVfv;0ZVBUAE<~xfqJmpecI4a& z5$BI<7C7h&H@4~B?qy3-ZQ5lGfFEWBXCx#pF#)U354M@jlu|Gm)* z<5u1ecywse)on9t#a?HkXf!>lyLZDA!#O1pf|)a>cX)_>demUQt;%b~o`(MX^S5z^ z$Tht&n|7(y++BDNli*c?oVCFNWE^cbUMpG#>SYHUV2PS$`xOJ;g#2_T_{yox7cw+& zL`hI+R~$MCqX$7;3%=3z^ptN1R!F#UGeP}|V!Qm+Q{&!CeeuK!V$G%Bs`B&x=Y%U1 z9NA)7ZBkTQzTiu9UdWJQ@0Zv%Cf6d%v*iSLwTK9^g>=rJ;{@ zKgB!ls#Nte*=Roha}bv7rO)8(@1M(ie^%(Y>7ON3d=q)-@_)tKe5xIps(G_}`l^I` RYw5OZTCcq>;m6%S{U7eA_R;_V literal 0 HcmV?d00001 diff --git a/loadtests/chart_db_mode_cold_10_p95.png b/loadtests/chart_db_mode_cold_10_p95.png new file mode 100644 index 0000000000000000000000000000000000000000..50228fc45c40f2e337b2c728fdf700730d663d49 GIT binary patch literal 30316 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

!&hVs9?!PtrXza>}`g_E18oFD^V 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

ssDaMK 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 literal 0 HcmV?d00001 diff --git a/loadtests/results/db_compare/postgres_async/benchmark_manifest.json b/loadtests/results/db_compare/postgres_async/benchmark_manifest.json new file mode 100644 index 0000000..0788baf --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/benchmark_manifest.json @@ -0,0 +1,118 @@ +{ + "scenarios": [ + "mixed", + "cache", + "optimize_hot", + "optimize_cold" + ], + "results": [ + { + "scenario": "mixed", + "users": 10, + "requests_per_s": 31.765306251711028, + "failure_rate": 0.0, + "p95_ms": 130.0, + "avg_ms": 53.74216636758446, + "passed": true + }, + { + "scenario": "mixed", + "users": 20, + "requests_per_s": 41.762599202568275, + "failure_rate": 0.0, + "p95_ms": 700.0, + "avg_ms": 169.63468771363304, + "passed": false + }, + { + "scenario": "mixed", + "users": 40, + "requests_per_s": 36.220388663349695, + "failure_rate": 0.0, + "p95_ms": 3100.0, + "avg_ms": 573.1448941060258, + "passed": false + }, + { + "scenario": "cache", + "users": 10, + "requests_per_s": 98.69318282692613, + "failure_rate": 0.0, + "p95_ms": 22.0, + "avg_ms": 12.702637761205079, + "passed": true + }, + { + "scenario": "cache", + "users": 20, + "requests_per_s": 173.1644083878897, + "failure_rate": 0.0, + "p95_ms": 40.0, + "avg_ms": 21.023119686805657, + "passed": true + }, + { + "scenario": "cache", + "users": 40, + "requests_per_s": 179.72490218244113, + "failure_rate": 0.0, + "p95_ms": 200.0, + "avg_ms": 99.25260803150522, + "passed": true + }, + { + "scenario": "optimize_hot", + "users": 10, + "requests_per_s": 19.18909392932623, + "failure_rate": 0.0, + "p95_ms": 23.0, + "avg_ms": 17.581401114762315, + "passed": true + }, + { + "scenario": "optimize_hot", + "users": 20, + "requests_per_s": 36.987391461391404, + "failure_rate": 0.0, + "p95_ms": 33.0, + "avg_ms": 19.519349806737534, + "passed": true + }, + { + "scenario": "optimize_hot", + "users": 40, + "requests_per_s": 68.61946835160887, + "failure_rate": 0.0, + "p95_ms": 100.0, + "avg_ms": 30.016994813331056, + "passed": true + }, + { + "scenario": "optimize_cold", + "users": 10, + "requests_per_s": 0.17827204092836194, + "failure_rate": 0.0, + "p95_ms": 11000.0, + "avg_ms": 7144.589100003941, + "passed": false + }, + { + "scenario": "optimize_cold", + "users": 20, + "requests_per_s": 0.0, + "failure_rate": 1.0, + "p95_ms": Infinity, + "avg_ms": Infinity, + "passed": false + }, + { + "scenario": "optimize_cold", + "users": 40, + "requests_per_s": 0.0, + "failure_rate": 1.0, + "p95_ms": Infinity, + "avg_ms": Infinity, + "passed": false + } + ] +} \ No newline at end of file diff --git a/loadtests/results/db_compare/postgres_async/benchmark_summary.md b/loadtests/results/db_compare/postgres_async/benchmark_summary.md new file mode 100644 index 0000000..9e60a7d --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/benchmark_summary.md @@ -0,0 +1,16 @@ +# Benchmark Summary + +| Scenario | Users | RPS | P95 (ms) | Avg (ms) | Failure % | Pass | +| --- | ---: | ---: | ---: | ---: | ---: | :---: | +| mixed | 10 | 31.77 | 130.00 | 53.74 | 0.00 | yes | +| mixed | 20 | 41.76 | 700.00 | 169.63 | 0.00 | no | +| mixed | 40 | 36.22 | 3100.00 | 573.14 | 0.00 | no | +| cache | 10 | 98.69 | 22.00 | 12.70 | 0.00 | yes | +| cache | 20 | 173.16 | 40.00 | 21.02 | 0.00 | yes | +| cache | 40 | 179.72 | 200.00 | 99.25 | 0.00 | yes | +| optimize_hot | 10 | 19.19 | 23.00 | 17.58 | 0.00 | yes | +| optimize_hot | 20 | 36.99 | 33.00 | 19.52 | 0.00 | yes | +| optimize_hot | 40 | 68.62 | 100.00 | 30.02 | 0.00 | yes | +| optimize_cold | 10 | 0.18 | 11000.00 | 7144.59 | 0.00 | no | +| optimize_cold | 20 | 0.00 | inf | inf | 100.00 | no | +| optimize_cold | 40 | 0.00 | inf | inf | 100.00 | no | diff --git a/loadtests/results/db_compare/postgres_async/cache/u10_exceptions.csv b/loadtests/results/db_compare/postgres_async/cache/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/cache/u10_failures.csv b/loadtests/results/db_compare/postgres_async/cache/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/cache/u10_stats.csv b/loadtests/results/db_compare/postgres_async/cache/u10_stats.csv new file mode 100644 index 0000000..5bb9153 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u10_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts [hot],360,0,11,12.601189998612325,6.225299992365763,129.75559997721575,4161.0,25.653101673424842,0.0,11,13,14,15,18,21,31,34,130,130,130 +GET,GET /prompts/search [hot],279,0,10,12.15402222220539,6.68049999512732,134.23630001489073,5115.0,19.881153796904254,0.0,10,13,14,14,18,20,26,36,130,130,130 +GET,GET /prompts/{project}/{name} [hot],482,0,10,11.80159916944997,6.213099986780435,99.28320001927204,425.0,34.346652796085486,0.0,10,12,13,14,17,21,26,29,99,99,99 +GET,GET /prompts/{project}/{name}/versions [hot],201,0,10,12.657361691794474,5.954699998255819,104.04549998929724,272.0,14.322981767662204,0.0,10,12,14,14,18,22,27,100,100,100,100 +POST,POST /optimize [hot],50,0,20,24.36687200097367,11.07789998059161,99.64990001753904,753.0,3.5629307879756724,0.0,21,26,27,30,32,33,100,100,100,100,100 +POST,POST /prompts [seed],12,0,15,15.77874999929918,13.091900007566437,19.938899989938363,34.0,0.8551033891141614,0.0,16,16,18,18,18,20,20,20,20,20,20 +PUT,PUT /optimize/config,1,0,25.56360000744462,25.56360000744462,25.56360000744462,25.56360000744462,507.0,0.07125861575951345,0.0,26,26,26,26,26,26,26,26,26,26,26 +,Aggregated,1385,0,11,12.702637761205079,5.954699998255819,134.23630001489073,2327.171119133574,98.69318282692613,0.0,11,13,14,15,19,22,30,36,130,130,130 diff --git a/loadtests/results/db_compare/postgres_async/cache/u10_stats_history.csv b/loadtests/results/db_compare/postgres_async/cache/u10_stats_history.csv new file mode 100644 index 0000000..c2f7873 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168568,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168569,10,,Aggregated,0.000000,0.000000,13,15,16,19,98,100,130,130,130,130,130,84,0,13,24.09792261841219,6.331200012937188,134.23630001489073,1838.0 +1779168570,10,,Aggregated,0.000000,0.000000,12,14,15,18,30,97,100,130,130,130,130,182,0,11,18.34665274661377,6.331200012937188,134.23630001489073,2307.71978021978 +1779168571,10,,Aggregated,24.000000,0.000000,11,13,15,16,22,31,100,130,130,130,130,285,0,11,15.76303894633607,6.225299992365763,134.23630001489073,2345.315789473684 +1779168572,10,,Aggregated,60.000000,0.000000,11,13,14,16,21,30,99,100,130,130,130,379,0,11,14.762686014772543,6.225299992365763,134.23630001489073,2334.3562005277045 +1779168573,10,,Aggregated,73.000000,0.000000,11,13,14,15,20,26,97,100,130,130,130,477,0,11,14.042781550299075,6.225299992365763,134.23630001489073,2385.6918238993712 +1779168574,10,,Aggregated,79.250000,0.000000,11,13,14,15,20,24,34,100,130,130,130,583,0,11,13.503836191101176,6.225299992365763,134.23630001489073,2352.8987993138935 +1779168575,10,,Aggregated,82.800000,0.000000,11,13,14,15,20,23,32,100,130,130,130,683,0,11,13.389574669865349,6.225299992365763,134.23630001489073,2335.2679355783307 +1779168576,10,,Aggregated,85.333333,0.000000,11,13,14,15,19,23,32,99,130,130,130,789,0,11,13.146587705557767,6.225299992365763,134.23630001489073,2334.370088719899 +1779168577,10,,Aggregated,88.142857,0.000000,11,13,14,15,19,23,32,98,130,130,130,880,0,11,13.079079545257793,6.218599999556318,134.23630001489073,2378.9886363636365 +1779168578,10,,Aggregated,89.625000,0.000000,11,13,14,15,19,22,31,97,130,130,130,981,0,11,12.888045973219471,5.954699998255819,134.23630001489073,2363.0560652395516 diff --git a/loadtests/results/db_compare/postgres_async/cache/u20_exceptions.csv b/loadtests/results/db_compare/postgres_async/cache/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/cache/u20_failures.csv b/loadtests/results/db_compare/postgres_async/cache/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/cache/u20_stats.csv b/loadtests/results/db_compare/postgres_async/cache/u20_stats.csv new file mode 100644 index 0000000..90886f1 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u20_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts [hot],640,0,18,20.095270311912827,6.5814999979920685,97.65520002110861,4161.0,45.644654599773226,0.0,18,21,25,27,32,38,52,75,98,98,98 +GET,GET /prompts/search [hot],501,0,19,20.250058083986946,6.269599980441853,97.41560000111349,5115.0,35.73120617888498,0.0,19,22,25,26,32,36,44,77,97,97,97 +GET,GET /prompts/{project}/{name} [hot],829,0,18,20.329840410048867,6.176700000651181,96.98699999717064,425.0,59.12409166126876,0.0,18,22,25,27,33,36,48,66,97,97,97 +GET,GET /prompts/{project}/{name}/versions [hot],341,0,18,21.081690028971444,6.2049999833106995,97.1441000001505,272.0,24.320042528941674,0.0,18,22,25,28,34,42,73,76,97,97,97 +POST,POST /optimize [hot],104,0,33,36.10298750041133,11.367900006007403,115.11710000922903,753.0,7.417256372463149,0.0,33,40,45,48,53,68,97,99,120,120,120 +POST,POST /prompts [seed],12,0,15,18.088133333852358,12.945700000273064,46.44590002135374,34.0,0.855837273745748,0.0,16,16,17,17,19,46,46,46,46,46,46 +PUT,PUT /optimize/config,1,0,23.820200003683567,23.820200003683567,23.820200003683567,23.820200003683567,507.0,0.07131977281214567,0.0,24,24,24,24,24,24,24,24,24,24,24 +,Aggregated,2428,0,18,21.023119686805657,6.176700000651181,115.11710000922903,2368.1873970345964,173.1644083878897,0.0,18,22,25,28,33,40,56,75,98,120,120 diff --git a/loadtests/results/db_compare/postgres_async/cache/u20_stats_history.csv b/loadtests/results/db_compare/postgres_async/cache/u20_stats_history.csv new file mode 100644 index 0000000..4151373 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168584,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168585,10,,Aggregated,0.000000,0.000000,13,15,16,17,34,38,49,50,50,50,50,89,0,13,15.497910112909073,6.3633999961894006,49.99390000011772,2231.0112359550562 +1779168586,20,,Aggregated,0.000000,0.000000,16,20,24,34,47,69,79,81,96,96,96,252,0,16,22.284598016186564,6.3633999961894006,95.72830001707189,2356.2301587301586 +1779168587,20,,Aggregated,6.000000,0.000000,18,23,28,31,40,48,76,80,96,96,96,443,0,18,22.141476072167084,6.2049999833106995,95.72830001707189,2354.0474040632052 +1779168588,20,,Aggregated,53.500000,0.000000,19,22,25,28,35,46,69,78,96,96,96,630,0,19,21.318012222302116,6.2049999833106995,95.72830001707189,2329.0412698412697 +1779168589,20,,Aggregated,91.000000,0.000000,19,23,26,29,34,45,50,76,96,96,96,812,0,19,21.330882142648605,6.2049999833106995,95.72830001707189,2414.543103448276 +1779168590,20,,Aggregated,115.250000,0.000000,19,23,26,29,34,46,56,76,96,96,96,990,0,19,21.438288787906668,6.2049999833106995,95.72830001707189,2449.3242424242426 +1779168591,20,,Aggregated,129.600000,0.000000,19,22,25,28,33,44,54,73,82,96,96,1171,0,19,20.86507941931836,6.2049999833106995,95.72830001707189,2435.929120409906 +1779168592,20,,Aggregated,138.166667,0.000000,18,22,25,28,34,43,53,68,82,96,96,1352,0,18,20.839654585827397,6.2049999833106995,95.72830001707189,2429.1597633136093 +1779168593,20,,Aggregated,143.714286,0.000000,18,22,25,27,33,41,53,66,82,96,96,1519,0,18,20.655127913128418,6.2049999833106995,95.72830001707189,2418.5358788676763 +1779168594,20,,Aggregated,148.250000,0.000000,18,23,25,28,33,44,59,78,99,120,120,1704,0,18,21.212472241767983,6.2049999833106995,115.11710000922903,2395.7153755868544 diff --git a/loadtests/results/db_compare/postgres_async/cache/u40_exceptions.csv b/loadtests/results/db_compare/postgres_async/cache/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/cache/u40_failures.csv b/loadtests/results/db_compare/postgres_async/cache/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/cache/u40_stats.csv b/loadtests/results/db_compare/postgres_async/cache/u40_stats.csv new file mode 100644 index 0000000..5b94413 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u40_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts [hot],663,0,92,95.19135218697845,7.058200018946081,436.1969000019599,4161.0,47.37877143020218,0.0,92,99,100,110,130,190,240,400,440,440,440 +GET,GET /prompts/search [hot],508,0,92,95.11648110028331,6.310600001597777,494.8045000201091,5115.0,36.30228640504179,0.0,92,98,100,110,130,170,270,420,490,490,490 +GET,GET /prompts/{project}/{name} [hot],869,0,90,92.87426260088853,6.547400000272319,470.9006000193767,425.0,62.09977733460889,0.0,90,98,100,110,130,180,240,360,470,470,470 +GET,GET /prompts/{project}/{name}/versions [hot],294,0,90,95.11044829955948,6.418699980713427,441.51209999108687,272.0,21.00959095094938,0.0,91,100,100,110,130,170,260,400,440,440,440 +POST,POST /optimize [hot],168,0,160,174.6022767862693,11.04689997737296,558.7517999811098,753.0,12.005480543399647,0.0,160,180,190,200,270,320,540,560,560,560,560 +POST,POST /prompts [seed],12,0,13,13.64945000144265,12.431600014679134,15.650700021069497,34.0,0.8575343245285462,0.0,14,14,14,14,15,16,16,16,16,16,16 +PUT,PUT /optimize/config,1,0,22.088399971835315,22.088399971835315,22.088399971835315,22.088399971835315,507.0,0.07146119371071219,0.0,22,22,22,22,22,22,22,22,22,22,22 +,Aggregated,2515,0,93,99.25260803150522,6.310600001597777,558.7517999811098,2359.393638170974,179.72490218244113,0.0,93,100,110,120,150,200,270,400,540,560,560 diff --git a/loadtests/results/db_compare/postgres_async/cache/u40_stats_history.csv b/loadtests/results/db_compare/postgres_async/cache/u40_stats_history.csv new file mode 100644 index 0000000..527269b --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/cache/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168601,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168602,10,,Aggregated,0.000000,0.000000,11,13,15,16,34,36,47,48,48,48,48,91,0,11,14.160656042404698,6.547400000272319,47.7000999962911,1691.4615384615386 +1779168603,20,,Aggregated,0.000000,0.000000,16,21,26,32,39,43,48,64,75,75,75,270,0,16,19.93771148101789,6.310600001597777,75.2243000024464,2168.303703703704 +1779168604,40,,Aggregated,68.000000,0.000000,29,44,52,54,75,200,270,270,310,310,310,431,0,29,45.52096171714333,6.310600001597777,308.0195000220556,2251.7470997679816 +1779168605,40,,Aggregated,113.500000,0.000000,44,63,76,100,220,380,440,490,560,560,560,567,0,44,81.44383174599258,6.310600001597777,558.7517999811098,2248.1181657848324 +1779168606,40,,Aggregated,129.333333,0.000000,63,87,110,120,190,360,430,470,560,560,560,757,0,63,88.00302140009953,6.310600001597777,558.7517999811098,2263.233817701453 +1779168607,40,,Aggregated,130.000000,0.000000,76,94,110,120,150,270,430,440,560,560,560,962,0,76,89.74834002055597,6.310600001597777,558.7517999811098,2281.95841995842 +1779168608,40,,Aggregated,144.000000,0.000000,82,96,110,120,190,260,400,440,560,560,560,1134,0,82,95.25643174588471,6.310600001597777,558.7517999811098,2277.0458553791887 +1779168609,40,,Aggregated,151.666667,0.000000,86,100,120,130,190,250,390,430,560,560,560,1310,0,86,99.59538305340023,6.310600001597777,558.7517999811098,2287.9740458015267 +1779168610,40,,Aggregated,154.857143,0.000000,87,99,110,120,180,240,370,430,560,560,560,1521,0,87,98.21350618000669,6.310600001597777,558.7517999811098,2313.1505588428668 +1779168611,40,,Aggregated,157.875000,0.000000,89,100,110,120,170,230,370,430,560,560,560,1723,0,89,98.79690487494771,6.310600001597777,558.7517999811098,2347.561810795125 diff --git a/loadtests/results/db_compare/postgres_async/mixed/u10_exceptions.csv b/loadtests/results/db_compare/postgres_async/mixed/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/mixed/u10_failures.csv b/loadtests/results/db_compare/postgres_async/mixed/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/mixed/u10_stats.csv b/loadtests/results/db_compare/postgres_async/mixed/u10_stats.csv new file mode 100644 index 0000000..1fc1184 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u10_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts,269,0,13,36.411173605545315,7.277500000782311,224.18079999624752,9684.791821561339,19.158895474686698,0.0,13,24,52,65,100,130,180,220,220,220,220 +GET,GET /prompts/search,84,0,16,51.67062737988696,7.718500011833385,173.5525000258349,13725.809523809523,5.982703419604768,0.0,16,71,99,120,130,150,160,170,170,170,170 +POST,POST /optimize,4,0,17,1381.488500002888,12.122200016165152,5466.924800013658,753.0,0.28489063902879846,0.0,30,30,5500,5500,5500,5500,5500,5500,5500,5500,5500 +POST,POST /prompts,38,0,38,45.701463158413965,25.75600001728162,141.32120000431314,400.0,2.706461070773585,0.0,39,46,54,57,68,100,140,140,140,140,140 +POST,POST /prompts [seed],12,0,28,30.489300006593112,26.82739999727346,47.64820000855252,425.1666666666667,0.8546719170863953,0.0,29,29,33,33,34,48,48,48,48,48,48 +PUT,PUT /optimize/config,1,0,30.756300024222583,30.756300024222583,30.756300024222583,30.756300024222583,507.0,0.07122265975719962,0.0,31,31,31,31,31,31,31,31,31,31,31 +PUT,PUT /prompts/{project}/{name},38,0,48,57.232376315943455,32.462500006658956,115.80620001768693,254.0,2.706461070773585,0.0,50,61,74,80,95,110,120,120,120,120,120 +,Aggregated,446,0,23,53.74216636758446,7.277500000782311,5466.924800013658,8501.45739910314,31.765306251711028,0.0,23,43,60,74,110,130,160,220,5500,5500,5500 diff --git a/loadtests/results/db_compare/postgres_async/mixed/u10_stats_history.csv b/loadtests/results/db_compare/postgres_async/mixed/u10_stats_history.csv new file mode 100644 index 0000000..c0de5e8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168519,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168520,10,,Aggregated,0.000000,0.000000,48,95,140,140,220,220,220,220,220,220,220,32,0,34,82.9721656282345,22.902100026840344,224.18079999624752,4143.21875 +1779168521,10,,Aggregated,0.000000,0.000000,63,79,92,120,140,220,220,220,220,220,220,65,0,63,72.48892769387636,10.57859999127686,224.18079999624752,5375.569230769231 +1779168522,10,,Aggregated,17.000000,0.000000,58,77,95,120,140,220,220,220,220,220,220,95,0,58,66.64074947414184,7.7923999924678355,224.18079999624752,6221.0526315789475 +1779168523,10,,Aggregated,25.500000,0.000000,46,74,89,110,130,160,220,220,220,220,220,125,0,46,61.98224480077624,7.7923999924678355,224.18079999624752,6743.832 +1779168524,10,,Aggregated,27.000000,0.000000,40,68,86,99,130,160,220,220,220,220,220,154,0,37,57.90371558509537,7.7923999924678355,224.18079999624752,7104.376623376624 +1779168525,10,,Aggregated,28.000000,0.000000,37,65,80,97,130,140,220,220,5500,5500,5500,185,0,37,84.40656864927527,7.7923999924678355,5466.924800013658,7016.254054054054 +1779168526,10,,Aggregated,28.600000,0.000000,27,57,74,84,130,140,220,220,5500,5500,5500,217,0,27,74.01773502324892,7.284499995876104,5466.924800013658,7416.087557603687 +1779168527,10,,Aggregated,28.833333,0.000000,27,50,71,83,120,140,220,220,5500,5500,5500,253,0,27,68.30941818184274,7.284499995876104,5466.924800013658,7641.790513833992 +1779168528,10,,Aggregated,28.714286,0.000000,26,48,65,79,120,140,220,220,5500,5500,5500,287,0,26,64.26913170734151,7.277500000782311,5466.924800013658,7766.623693379791 +1779168529,10,,Aggregated,29.625000,0.000000,26,48,65,77,120,130,180,220,5500,5500,5500,321,0,26,61.539619003068594,7.277500000782311,5466.924800013658,7918.647975077882 diff --git a/loadtests/results/db_compare/postgres_async/mixed/u20_exceptions.csv b/loadtests/results/db_compare/postgres_async/mixed/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/mixed/u20_failures.csv b/loadtests/results/db_compare/postgres_async/mixed/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/mixed/u20_stats.csv b/loadtests/results/db_compare/postgres_async/mixed/u20_stats.csv new file mode 100644 index 0000000..d5cac6d --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u20_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts,317,0,110,132.08164889628011,7.72069999948144,607.0922999933828,8893.27760252366,22.59171322050195,0.0,110,160,190,200,260,390,540,610,610,610,610 +GET,GET /prompts/search,85,0,500,469.5608258822604,9.38889998360537,950.6716000032611,41027.01176470588,6.057714901396422,0.0,500,700,720,790,830,870,900,950,950,950,950 +POST,POST /optimize,13,0,52,67.84240000818569,21.07710001291707,270.98850000766106,753.0,0.9264740437429823,0.0,52,58,64,64,81,270,270,270,270,270,270 +POST,POST /prompts,79,0,73,98.65969240582913,26.03930002078414,448.3831999823451,400.0,5.630111496591969,0.0,73,87,95,110,170,380,440,450,450,450,450 +POST,POST /prompts [seed],12,0,13,13.186283336835913,11.650400003418326,15.076700015924871,34.0,0.8552068096089067,0.0,13,13,14,14,14,15,15,15,15,15,15 +PUT,PUT /optimize/config,1,0,23.640800005523488,23.640800005523488,23.640800005523488,23.640800005523488,507.0,0.07126723413407555,0.0,24,24,24,24,24,24,24,24,24,24,24 +PUT,PUT /prompts/{project}/{name},79,0,95,110.95482278305778,47.269700007745996,462.52390000154264,254.0,5.630111496591969,0.0,95,110,120,140,180,210,340,460,460,460,460 +,Aggregated,586,0,96,169.63468771363304,7.72069999948144,950.6716000032611,10868.319112627987,41.762599202568275,0.0,96,150,190,230,460,700,820,870,950,950,950 diff --git a/loadtests/results/db_compare/postgres_async/mixed/u20_stats_history.csv b/loadtests/results/db_compare/postgres_async/mixed/u20_stats_history.csv new file mode 100644 index 0000000..c0a240e --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168535,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168536,10,,Aggregated,0.000000,0.000000,14,47,56,72,130,140,140,140,140,140,140,37,0,14,43.069129733437975,8.006399992154911,135.6404999969527,4576.135135135135 +1779168537,20,,Aggregated,0.000000,0.000000,70,130,180,440,540,610,610,670,670,670,670,75,0,70,160.20152133346224,8.006399992154911,668.3709999779239,5405.426666666666 +1779168538,20,,Aggregated,25.000000,0.000000,88,130,160,230,450,580,610,610,670,670,670,124,0,86,148.94057096793287,8.006399992154911,668.3709999779239,6608.943548387097 +1779168539,20,,Aggregated,20.500000,0.000000,86,130,150,220,440,500,610,610,670,670,670,175,0,86,141.1446588576239,8.006399992154911,668.3709999779239,7184.828571428571 +1779168540,20,,Aggregated,31.000000,0.000000,88,140,180,230,410,480,610,610,670,670,670,206,0,87,144.4724480586479,8.006399992154911,668.3709999779239,7478.23786407767 +1779168541,20,,Aggregated,34.250000,0.000000,95,150,210,250,450,540,670,800,850,850,850,245,0,95,162.7581338783042,8.006399992154911,848.2294999994338,7923.771428571428 +1779168542,20,,Aggregated,37.400000,0.000000,110,160,230,260,460,610,800,830,850,850,850,289,0,110,173.68650726704524,8.006399992154911,848.2294999994338,8558.453287197231 +1779168543,20,,Aggregated,37.000000,0.000000,110,160,230,260,460,660,810,830,850,850,850,331,0,110,177.6251628403369,8.006399992154911,848.3662999933586,9191.287009063444 +1779168544,20,,Aggregated,36.571429,0.000000,100,150,210,250,460,630,800,830,850,850,850,373,0,100,172.27562868662724,7.72069999948144,848.3662999933586,9664.58981233244 +1779168545,20,,Aggregated,37.375000,0.000000,100,160,210,250,480,660,790,810,850,850,850,410,0,100,174.4581612198267,7.72069999948144,848.3662999933586,10040.163414634146 diff --git a/loadtests/results/db_compare/postgres_async/mixed/u40_exceptions.csv b/loadtests/results/db_compare/postgres_async/mixed/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/mixed/u40_failures.csv b/loadtests/results/db_compare/postgres_async/mixed/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/mixed/u40_stats.csv b/loadtests/results/db_compare/postgres_async/mixed/u40_stats.csv new file mode 100644 index 0000000..40c4190 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u40_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts,277,0,390,458.28808953097536,7.53249999252148,1232.9513000149745,8258.595667870037,19.71129206237302,0.0,390,620,760,830,930,1000,1200,1200,1200,1200,1200 +GET,GET /prompts/search,34,0,3600,3108.363944116932,35.96579999430105,4678.744799981359,65908.32352941176,2.419436570832789,0.0,3700,3800,4300,4400,4600,4600,4700,4700,4700,4700,4700 +POST,POST /optimize,29,0,130,178.42948620894057,55.27549999533221,497.42739999783225,753.0,2.063637075122085,0.0,130,170,190,230,430,490,500,500,500,500,500 +POST,POST /prompts,78,0,290,343.43464871740133,45.293200004380196,892.0622000005096,400.0,5.550472133086987,0.0,290,400,450,500,600,680,780,890,890,890,890 +POST,POST /prompts [seed],12,0,13,13.364883338605674,12.296500004595146,14.764800027478486,34.0,0.8539187897056904,0.0,13,13,14,14,15,15,15,15,15,15,15 +PUT,PUT /optimize/config,1,0,25.366799993207678,25.366799993207678,25.366799993207678,25.366799993207678,507.0,0.07115989914214085,0.0,25,25,25,25,25,25,25,25,25,25,25 +PUT,PUT /prompts/{project}/{name},78,0,300,345.5445269210031,52.76280001271516,673.1220000074245,254.0,5.550472133086987,0.0,310,390,450,480,570,600,660,670,670,670,670 +,Aggregated,509,0,330,573.1448941060258,7.53249999252148,4678.744799981359,9041.803536345777,36.220388663349695,0.0,330,510,650,750,940,3100,3900,4600,4700,4700,4700 diff --git a/loadtests/results/db_compare/postgres_async/mixed/u40_stats_history.csv b/loadtests/results/db_compare/postgres_async/mixed/u40_stats_history.csv new file mode 100644 index 0000000..20038a3 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/mixed/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168551,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168552,10,,Aggregated,0.000000,0.000000,13,25,55,73,140,140,260,260,260,260,260,42,0,13,42.503190477700194,7.53249999252148,257.91929999832064,7763.333333333333 +1779168553,20,,Aggregated,0.000000,0.000000,53,100,150,150,260,280,880,890,890,890,890,80,0,45,105.27996000091662,7.53249999252148,890.3409999911673,8211.3875 +1779168554,30,,Aggregated,4.000000,0.000000,140,240,280,500,810,910,910,910,1600,1600,1600,110,0,130,245.29793727322777,7.53249999252148,1551.2052999984007,7965.727272727273 +1779168555,40,,Aggregated,21.500000,0.000000,140,260,460,650,830,910,910,910,1600,1600,1600,120,0,140,271.2473366676325,7.53249999252148,1551.2052999984007,7436.883333333333 +1779168556,40,,Aggregated,27.333333,0.000000,190,340,610,700,910,1200,1600,3100,3200,3200,3200,172,0,180,380.05637441951984,7.53249999252148,3173.7466999911703,8165.372093023256 +1779168557,40,,Aggregated,27.750000,0.000000,260,480,680,780,990,1200,1600,3100,3900,3900,3900,209,0,260,432.3011459330148,7.53249999252148,3885.2197999949567,7869.875598086124 +1779168558,40,,Aggregated,24.400000,0.000000,270,450,660,730,1000,1200,3600,3700,3900,3900,3900,253,0,270,496.4594596839536,7.53249999252148,3885.2197999949567,9135.97233201581 +1779168559,40,,Aggregated,29.000000,0.000000,290,490,680,810,970,1200,3600,3700,3900,3900,3900,289,0,290,506.8255252598392,7.53249999252148,3885.2197999949567,8648.692041522492 +1779168560,40,,Aggregated,29.857143,0.000000,310,470,670,750,960,1200,3700,3700,3900,3900,3900,327,0,310,510.67537859360624,7.53249999252148,3885.2197999949567,8540.80122324159 +1779168561,40,,Aggregated,31.625000,0.000000,320,480,650,710,960,1200,3600,3700,3900,3900,3900,356,0,310,527.416445786677,7.53249999252148,3885.2197999949567,8735.91573033708 diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u10_exceptions.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u10_failures.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u10_stats.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u10_stats.csv new file mode 100644 index 0000000..d71069b --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u10_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [cold],2,0,3300,7144.589100003941,3274.135899991961,11015.04230001592,1103.5,0.17827204092836194,0.0,11000,11000,11000,11000,11000,11000,11000,11000,11000,11000,11000 +POST,POST /prompts [seed],12,0,14,14.621783331676852,12.828399980207905,17.21489999908954,34.0,1.0696322455701717,0.0,15,15,16,16,17,17,17,17,17,17,17 +PUT,PUT /optimize/config,1,0,24.303499987581745,24.303499987581745,24.303499987581745,24.303499987581745,507.0,0.08913602046418097,0.0,24,24,24,24,24,24,24,24,24,24,24 +,Aggregated,15,0,15,965.9295399983724,12.828399980207905,11015.04230001592,208.13333333333333,1.3370403069627146,0.0,15,16,17,24,3300,11000,11000,11000,11000,11000,11000 diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u10_stats_history.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u10_stats_history.csv new file mode 100644 index 0000000..459b74b --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168666,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168667,10,,Aggregated,0.000000,0.000000,15,16,16,17,17,24,24,24,24,24,24,13,0,15,15.36653076674646,12.828399980207905,24.303499987581745,70.38461538461539 +1779168668,10,,Aggregated,0.000000,0.000000,15,16,16,17,17,24,24,24,24,24,24,13,0,15,15.36653076674646,12.828399980207905,24.303499987581745,70.38461538461539 +1779168669,10,,Aggregated,0.000000,0.000000,15,16,16,17,17,24,24,24,24,24,24,13,0,15,15.36653076674646,12.828399980207905,24.303499987581745,70.38461538461539 +1779168670,10,,Aggregated,6.500000,0.000000,15,16,17,17,24,3300,3300,3300,3300,3300,3300,14,0,15,248.13577142569036,12.828399980207905,3274.135899991961,155.28571428571428 +1779168671,10,,Aggregated,6.500000,0.000000,15,16,17,17,24,3300,3300,3300,3300,3300,3300,14,0,15,248.13577142569036,12.828399980207905,3274.135899991961,155.28571428571428 +1779168672,10,,Aggregated,6.500000,0.000000,15,16,17,17,24,3300,3300,3300,3300,3300,3300,14,0,15,248.13577142569036,12.828399980207905,3274.135899991961,155.28571428571428 +1779168673,10,,Aggregated,6.500000,0.000000,15,16,17,17,24,3300,3300,3300,3300,3300,3300,14,0,15,248.13577142569036,12.828399980207905,3274.135899991961,155.28571428571428 +1779168674,10,,Aggregated,6.500000,0.000000,15,16,17,17,24,3300,3300,3300,3300,3300,3300,14,0,15,248.13577142569036,12.828399980207905,3274.135899991961,155.28571428571428 +1779168675,10,,Aggregated,6.500000,0.000000,15,16,17,17,24,3300,3300,3300,3300,3300,3300,14,0,15,248.13577142569036,12.828399980207905,3274.135899991961,155.28571428571428 +1779168676,10,,Aggregated,6.500000,0.000000,15,16,17,17,24,3300,3300,3300,3300,3300,3300,14,0,15,248.13577142569036,12.828399980207905,3274.135899991961,155.28571428571428 diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u20_exceptions.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u20_failures.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u20_stats.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u20_stats.csv new file mode 100644 index 0000000..29610b2 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u20_stats.csv @@ -0,0 +1,4 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /prompts [seed],12,0,22,22.471658334931515,18.475300021236762,25.37389998906292,34.0,39.17798818705184,0.0,23,23,25,25,25,25,25,25,25,25,25 +PUT,PUT /optimize/config,1,0,34.63689997442998,34.63689997442998,34.63689997442998,34.63689997442998,507.0,3.2648323489209865,0.0,35,35,35,35,35,35,35,35,35,35,35 +,Aggregated,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539,42.44282053597283,0.0,23,24,25,25,25,35,35,35,35,35,35 diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u20_stats_history.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u20_stats_history.csv new file mode 100644 index 0000000..1b847cd --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168683,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168684,10,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 +1779168685,20,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 +1779168686,20,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 +1779168687,20,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 +1779168688,20,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 +1779168689,20,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 +1779168690,20,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 +1779168691,20,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 +1779168692,20,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 +1779168693,20,,Aggregated,0.000000,0.000000,23,24,25,25,25,35,35,35,35,35,35,13,0,23,23.407446153354474,18.475300021236762,34.63689997442998,70.38461538461539 diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u40_exceptions.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u40_failures.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u40_stats.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u40_stats.csv new file mode 100644 index 0000000..43da8ba --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u40_stats.csv @@ -0,0 +1,4 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /prompts [seed],12,0,22,22.124233333064087,21.187300008023158,24.445400020340458,34.0,39.212448473506946,0.0,22,22,23,23,23,24,24,24,24,24,24 +PUT,PUT /optimize/config,1,0,37.603999982820824,37.603999982820824,37.603999982820824,37.603999982820824,507.0,3.2677040394589123,0.0,38,38,38,38,38,38,38,38,38,38,38 +,Aggregated,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539,42.48015251296586,0.0,22,23,23,23,24,38,38,38,38,38,38 diff --git a/loadtests/results/db_compare/postgres_async/optimize_cold/u40_stats_history.csv b/loadtests/results/db_compare/postgres_async/optimize_cold/u40_stats_history.csv new file mode 100644 index 0000000..0e61581 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_cold/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168700,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168701,10,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 +1779168702,20,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 +1779168703,40,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 +1779168704,40,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 +1779168705,40,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 +1779168706,40,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 +1779168707,40,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 +1779168708,40,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 +1779168709,40,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 +1779168710,40,,Aggregated,0.000000,0.000000,22,23,23,23,24,38,38,38,38,38,38,13,0,22,23.314984613814605,21.187300008023158,37.603999982820824,70.38461538461539 diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u10_exceptions.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u10_failures.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u10_stats.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u10_stats.csv new file mode 100644 index 0000000..e50a9b1 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u10_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [hot],269,0,14,17.581401114762315,11.123499978566542,97.4757999938447,753.0,19.18909392932623,0.0,14,15,16,17,18,23,95,97,97,97,97 +POST,POST /prompts [seed],12,0,15,15.748158334948434,13.262599997688085,17.968500003917143,34.0,0.8560190600442928,0.0,16,16,17,17,18,18,18,18,18,18,18 +PUT,PUT /optimize/config,1,0,24.855800002114847,24.855800002114847,24.855800002114847,24.855800002114847,507.0,0.07133492167035774,0.0,25,25,25,25,25,25,25,25,25,25,25 +,Aggregated,282,0,14,17.5291865244417,11.123499978566542,97.4757999938447,721.531914893617,20.116447911040883,0.0,14,16,17,17,18,23,95,97,97,97,97 diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u10_stats_history.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u10_stats_history.csv new file mode 100644 index 0000000..a319db6 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168617,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168618,10,,Aggregated,0.000000,0.000000,17,25,94,95,97,97,97,97,97,97,97,30,0,16,41.50574333325494,12.110100011341274,97.4757999938447,457.2 +1779168619,10,,Aggregated,0.000000,0.000000,16,17,19,77,96,97,97,97,97,97,97,47,0,16,31.853585105211017,12.110100011341274,97.4757999938447,564.1914893617021 +1779168620,10,,Aggregated,14.000000,0.000000,16,17,19,20,95,96,97,97,97,97,97,66,0,16,27.63073030412973,12.110100011341274,97.4757999938447,618.5454545454545 +1779168621,10,,Aggregated,17.500000,0.000000,16,17,18,19,89,96,97,97,97,97,97,89,0,16,24.388634832248908,12.110100011341274,97.4757999938447,653.2921348314607 +1779168622,10,,Aggregated,17.666667,0.000000,15,17,18,18,22,95,97,97,97,97,97,112,0,15,22.30251607163934,11.123499978566542,97.4757999938447,673.7678571428571 +1779168623,10,,Aggregated,19.000000,0.000000,15,17,17,18,22,95,97,97,97,97,97,131,0,15,21.317769465159326,11.123499978566542,97.4757999938447,685.2595419847328 +1779168624,10,,Aggregated,19.000000,0.000000,15,16,17,18,21,94,97,97,97,97,97,150,0,15,20.4043719995146,11.123499978566542,97.4757999938447,693.84 +1779168625,10,,Aggregated,19.666667,0.000000,14,16,17,18,20,89,96,97,97,97,97,168,0,14,19.646477379865246,11.123499978566542,97.4757999938447,700.1785714285714 +1779168626,10,,Aggregated,19.571429,0.000000,14,16,17,17,20,77,96,97,97,97,97,186,0,14,19.17239139685833,11.123499978566542,97.4757999938447,705.2903225806451 +1779168627,10,,Aggregated,19.625000,0.000000,14,16,17,17,20,30,96,97,97,97,97,207,0,14,18.727881158617716,11.123499978566542,97.4757999938447,710.1304347826087 diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u20_exceptions.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u20_failures.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u20_stats.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u20_stats.csv new file mode 100644 index 0000000..13e38b5 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u20_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [hot],518,0,15,19.519349806737534,10.385100002167746,174.0740000095684,753.0,36.987391461391404,0.0,15,17,18,19,23,33,140,140,170,170,170 +POST,POST /prompts [seed],12,0,15,14.764774998184294,13.615100004244596,16.000399977201596,34.0,0.8568507674453607,0.0,15,15,15,15,16,16,16,16,16,16,16 +PUT,PUT /optimize/config,1,0,23.943899985169992,23.943899985169992,23.943899985169992,23.943899985169992,507.0,0.07140423062044672,0.0,24,24,24,24,24,24,24,24,24,24,24 +,Aggregated,531,0,15,19.42023427467688,10.385100002167746,174.0740000095684,736.2881355932203,37.915646459457214,0.0,15,17,18,19,23,31,140,140,170,170,170 diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u20_stats_history.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u20_stats_history.csv new file mode 100644 index 0000000..a2d5da0 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168633,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168634,10,,Aggregated,0.000000,0.000000,15,24,53,57,60,62,63,63,63,63,63,32,0,15,28.347403122097603,12.304899981245399,63.05280001834035,475.6875 +1779168635,20,,Aggregated,0.000000,0.000000,16,25,58,61,140,160,170,170,170,170,170,71,0,16,44.47758591505961,11.712500010617077,174.0740000095684,628.0140845070423 +1779168636,20,,Aggregated,1.000000,0.000000,16,19,24,27,140,140,170,170,170,170,170,113,0,16,33.86533274330751,10.914499987848103,174.0740000095684,674.4690265486726 +1779168637,20,,Aggregated,16.000000,0.000000,15,17,20,22,60,140,160,170,170,170,170,151,0,15,28.998333774584143,10.799000010592863,174.0740000095684,694.2317880794702 +1779168638,20,,Aggregated,24.333333,0.000000,15,17,19,22,57,140,160,170,170,170,170,185,0,15,26.402625404896114,10.799000010592863,174.0740000095684,705.0324324324324 +1779168639,20,,Aggregated,28.500000,0.000000,15,16,18,20,27,140,150,170,170,170,170,222,0,15,24.38268288227977,10.799000010592863,174.0740000095684,713.027027027027 +1779168640,20,,Aggregated,30.200000,0.000000,15,16,17,20,25,62,140,170,170,170,170,261,0,15,23.05450344768663,10.799000010592863,174.0740000095684,719.0 +1779168641,20,,Aggregated,31.000000,0.000000,15,16,17,19,24,61,140,170,170,170,170,300,0,15,22.05510166619206,10.480500000994653,174.0740000095684,723.42 +1779168642,20,,Aggregated,31.857143,0.000000,15,16,17,19,23,59,140,160,170,170,170,338,0,15,21.273081952319536,10.480500000994653,174.0740000095684,726.7455621301775 +1779168643,20,,Aggregated,32.750000,0.000000,15,16,17,19,23,57,140,160,170,170,170,373,0,15,20.807060857344638,10.480500000994653,174.0740000095684,729.2091152815013 diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u40_exceptions.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u40_failures.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u40_stats.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u40_stats.csv new file mode 100644 index 0000000..241d88d --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u40_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [hot],964,0,20,30.016994813331056,9.861200000159442,234.28259999491274,753.0,68.61946835160887,0.0,20,25,28,30,44,100,190,200,230,230,230 +POST,POST /prompts [seed],12,0,14,14.540891660241565,12.682699976721779,16.975499980617315,34.0,0.8541842533395296,0.0,14,14,16,16,16,17,17,17,17,17,17 +PUT,PUT /optimize/config,1,0,22.853000002214685,22.853000002214685,22.853000002214685,22.853000002214685,507.0,0.07118202111162747,0.0,23,23,23,23,23,23,23,23,23,23,23 +,Aggregated,977,0,20,29.81957697029299,9.861200000159442,234.28259999491274,743.9170931422723,69.54483462606004,0.0,20,24,28,30,44,100,190,200,230,230,230 diff --git a/loadtests/results/db_compare/postgres_async/optimize_hot/u40_stats_history.csv b/loadtests/results/db_compare/postgres_async/optimize_hot/u40_stats_history.csv new file mode 100644 index 0000000..d31d946 --- /dev/null +++ b/loadtests/results/db_compare/postgres_async/optimize_hot/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168650,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168651,10,,Aggregated,0.000000,0.000000,14,17,59,62,65,65,66,66,66,66,66,33,0,14,28.424336361543585,11.39950001379475,65.87839999701828,484.09090909090907 +1779168652,30,,Aggregated,0.000000,0.000000,18,44,63,65,200,200,230,230,230,230,230,72,0,17,53.69366805340784,11.244199995417148,234.28259999491274,629.75 +1779168653,40,,Aggregated,28.000000,0.000000,22,44,65,140,200,200,210,230,230,230,230,128,0,22,58.68708124853583,11.244199995417148,234.28259999491274,683.671875 +1779168654,40,,Aggregated,31.500000,0.000000,25,46,98,140,190,200,210,220,230,230,230,203,0,25,62.00556600884433,11.244199995417148,234.28259999491274,709.2857142857143 +1779168655,40,,Aggregated,38.333333,0.000000,23,29,41,65,180,200,200,220,230,230,230,286,0,23,49.85870489448885,10.895400017034262,234.28259999491274,721.972027972028 +1779168656,40,,Aggregated,47.500000,0.000000,21,27,31,39,150,190,200,210,230,230,230,361,0,21,43.50568088567416,10.693200019886717,234.28259999491274,728.4182825484764 +1779168657,40,,Aggregated,53.600000,0.000000,22,27,31,36,130,190,200,210,230,230,230,435,0,22,40.39303080437052,10.693200019886717,234.28259999491274,732.6 +1779168658,40,,Aggregated,57.333333,0.000000,20,25,29,32,98,180,200,200,230,230,230,506,0,20,37.10798043463188,10.693200019886717,234.28259999491274,735.4624505928854 +1779168659,40,,Aggregated,59.428571,0.000000,20,25,29,32,65,180,200,200,230,230,230,584,0,20,35.27459503441119,10.693200019886717,234.28259999491274,737.804794520548 +1779168660,40,,Aggregated,61.750000,0.000000,20,25,28,31,59,170,200,200,230,230,230,663,0,20,33.51850708884954,9.861200000159442,234.28259999491274,739.6153846153846 diff --git a/loadtests/results/db_compare/postgres_sync/benchmark_manifest.json b/loadtests/results/db_compare/postgres_sync/benchmark_manifest.json new file mode 100644 index 0000000..4c443a1 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/benchmark_manifest.json @@ -0,0 +1,118 @@ +{ + "scenarios": [ + "mixed", + "cache", + "optimize_hot", + "optimize_cold" + ], + "results": [ + { + "scenario": "mixed", + "users": 10, + "requests_per_s": 33.40169936083167, + "failure_rate": 0.0, + "p95_ms": 67.0, + "avg_ms": 40.87223991431404, + "passed": true + }, + { + "scenario": "mixed", + "users": 20, + "requests_per_s": 57.00252692771164, + "failure_rate": 0.0, + "p95_ms": 180.0, + "avg_ms": 57.75627556181178, + "passed": true + }, + { + "scenario": "mixed", + "users": 40, + "requests_per_s": 60.617113351173394, + "failure_rate": 0.0, + "p95_ms": 690.0, + "avg_ms": 264.264345828777, + "passed": false + }, + { + "scenario": "cache", + "users": 10, + "requests_per_s": 101.92505011069518, + "failure_rate": 0.0, + "p95_ms": 19.0, + "avg_ms": 10.845872206911288, + "passed": true + }, + { + "scenario": "cache", + "users": 20, + "requests_per_s": 186.39369632857841, + "failure_rate": 0.0, + "p95_ms": 28.0, + "avg_ms": 14.648941943761983, + "passed": true + }, + { + "scenario": "cache", + "users": 40, + "requests_per_s": 243.6223845218938, + "failure_rate": 0.0, + "p95_ms": 87.0, + "avg_ms": 51.00472133483273, + "passed": true + }, + { + "scenario": "optimize_hot", + "users": 10, + "requests_per_s": 19.757322343225336, + "failure_rate": 0.0, + "p95_ms": 20.0, + "avg_ms": 13.518698916443546, + "passed": true + }, + { + "scenario": "optimize_hot", + "users": 20, + "requests_per_s": 37.144558369695574, + "failure_rate": 0.0, + "p95_ms": 47.0, + "avg_ms": 15.613799424365613, + "passed": true + }, + { + "scenario": "optimize_hot", + "users": 40, + "requests_per_s": 70.68572057553776, + "failure_rate": 0.0, + "p95_ms": 43.0, + "avg_ms": 18.169772875068006, + "passed": true + }, + { + "scenario": "optimize_cold", + "users": 10, + "requests_per_s": 0.39290696393885094, + "failure_rate": 0.0, + "p95_ms": 13000.0, + "avg_ms": 7485.714840010041, + "passed": false + }, + { + "scenario": "optimize_cold", + "users": 20, + "requests_per_s": 0.0, + "failure_rate": 1.0, + "p95_ms": Infinity, + "avg_ms": Infinity, + "passed": false + }, + { + "scenario": "optimize_cold", + "users": 40, + "requests_per_s": 0.0, + "failure_rate": 1.0, + "p95_ms": Infinity, + "avg_ms": Infinity, + "passed": false + } + ] +} \ No newline at end of file diff --git a/loadtests/results/db_compare/postgres_sync/benchmark_summary.md b/loadtests/results/db_compare/postgres_sync/benchmark_summary.md new file mode 100644 index 0000000..2531451 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/benchmark_summary.md @@ -0,0 +1,16 @@ +# Benchmark Summary + +| Scenario | Users | RPS | P95 (ms) | Avg (ms) | Failure % | Pass | +| --- | ---: | ---: | ---: | ---: | ---: | :---: | +| mixed | 10 | 33.40 | 67.00 | 40.87 | 0.00 | yes | +| mixed | 20 | 57.00 | 180.00 | 57.76 | 0.00 | yes | +| mixed | 40 | 60.62 | 690.00 | 264.26 | 0.00 | no | +| cache | 10 | 101.93 | 19.00 | 10.85 | 0.00 | yes | +| cache | 20 | 186.39 | 28.00 | 14.65 | 0.00 | yes | +| cache | 40 | 243.62 | 87.00 | 51.00 | 0.00 | yes | +| optimize_hot | 10 | 19.76 | 20.00 | 13.52 | 0.00 | yes | +| optimize_hot | 20 | 37.14 | 47.00 | 15.61 | 0.00 | yes | +| optimize_hot | 40 | 70.69 | 43.00 | 18.17 | 0.00 | yes | +| optimize_cold | 10 | 0.39 | 13000.00 | 7485.71 | 0.00 | no | +| optimize_cold | 20 | 0.00 | inf | inf | 100.00 | no | +| optimize_cold | 40 | 0.00 | inf | inf | 100.00 | no | diff --git a/loadtests/results/db_compare/postgres_sync/cache/u10_exceptions.csv b/loadtests/results/db_compare/postgres_sync/cache/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/cache/u10_failures.csv b/loadtests/results/db_compare/postgres_sync/cache/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/cache/u10_stats.csv b/loadtests/results/db_compare/postgres_sync/cache/u10_stats.csv new file mode 100644 index 0000000..24f46ff --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u10_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts [hot],393,0,10,11.039386768221059,5.566500010900199,64.16049998369999,4161.0,27.972447411664252,0.0,10,11,12,13,15,19,23,46,64,64,64 +GET,GET /prompts/search [hot],298,0,10,10.42052617393459,5.2084000199101865,46.306800009915605,5115.0,21.210659869404445,0.0,10,11,12,13,15,19,20,22,46,46,46 +GET,GET /prompts/{project}/{name} [hot],472,0,9,10.573627543635666,4.9962000048253685,56.72349999076687,425.0,33.59540757838556,0.0,9,11,12,13,16,18,22,48,57,57,57 +GET,GET /prompts/{project}/{name}/versions [hot],199,0,9,9.933013065037173,5.139300017617643,22.597100003622472,272.0,14.164165483260016,0.0,9,11,12,12,14,17,19,20,23,23,23 +POST,POST /optimize [hot],57,0,14,16.709333333174552,8.339799998793751,60.79310001223348,941.0,4.057072525355884,0.0,14,16,20,20,22,38,53,61,61,61,61 +POST,POST /prompts [seed],12,0,12,11.976441669200236,10.577900015050545,14.31520000915043,34.0,0.8541205316538703,0.0,12,12,13,13,13,14,14,14,14,14,14 +PUT,PUT /optimize/config,1,0,23.92209999379702,23.92209999379702,23.92209999379702,23.92209999379702,507.0,0.07117671097115585,0.0,24,24,24,24,24,24,24,24,24,24,24 +,Aggregated,1432,0,10,10.845872206911288,4.9962000048253685,64.16049998369999,2422.3624301675977,101.92505011069518,0.0,10,11,12,13,16,19,22,27,62,64,64 diff --git a/loadtests/results/db_compare/postgres_sync/cache/u10_stats_history.csv b/loadtests/results/db_compare/postgres_sync/cache/u10_stats_history.csv new file mode 100644 index 0000000..486408a --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168221,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168222,10,,Aggregated,0.000000,0.000000,11,14,15,21,48,57,62,64,64,64,64,98,0,11,17.292902039599188,5.139300017617643,64.16049998369999,1649.6938775510205 +1779168223,10,,Aggregated,0.000000,0.000000,11,13,14,15,21,47,57,61,64,64,64,202,0,11,14.152462870265255,5.139300017617643,64.16049998369999,2025.8217821782177 +1779168224,10,,Aggregated,11.000000,0.000000,10,12,13,14,19,24,54,61,64,64,64,305,0,10,12.758765900870175,5.139300017617643,64.16049998369999,2279.075409836066 +1779168225,10,,Aggregated,56.000000,0.000000,10,12,13,14,18,22,49,57,64,64,64,405,0,10,12.152957283233487,5.139300017617643,64.16049998369999,2257.7925925925924 +1779168226,10,,Aggregated,71.000000,0.000000,10,11,13,14,17,20,47,54,64,64,64,512,0,10,11.622795312177914,5.139300017617643,64.16049998369999,2284.73046875 +1779168227,10,,Aggregated,79.750000,0.000000,10,11,12,13,16,20,46,54,64,64,64,609,0,10,11.323192939166077,5.139300017617643,64.16049998369999,2367.018062397373 +1779168228,10,,Aggregated,83.600000,0.000000,10,11,12,13,15,19,24,53,64,64,64,710,0,9,11.039658309686864,5.139300017617643,64.16049998369999,2403.443661971831 +1779168229,10,,Aggregated,87.166667,0.000000,10,11,12,13,15,20,24,49,64,64,64,808,0,10,11.025502351498794,5.139300017617643,64.16049998369999,2418.1188118811883 +1779168230,10,,Aggregated,88.714286,0.000000,10,11,12,13,16,20,24,48,64,64,64,913,0,10,11.050698795231662,5.095700005767867,64.16049998369999,2374.618838992333 +1779168231,10,,Aggregated,90.000000,0.000000,10,11,12,13,16,19,23,47,62,64,64,1014,0,10,10.903942702107106,4.9962000048253685,64.16049998369999,2398.842209072978 diff --git a/loadtests/results/db_compare/postgres_sync/cache/u20_exceptions.csv b/loadtests/results/db_compare/postgres_sync/cache/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/cache/u20_failures.csv b/loadtests/results/db_compare/postgres_sync/cache/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/cache/u20_stats.csv b/loadtests/results/db_compare/postgres_sync/cache/u20_stats.csv new file mode 100644 index 0000000..013cee2 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u20_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts [hot],734,0,13,14.617238147200114,4.938899975968525,64.74279999383725,4161.0,52.358581364399754,0.0,13,16,17,19,23,27,34,42,65,65,65 +GET,GET /prompts/search [hot],521,0,13,14.196251630356299,5.1629999943543226,66.98730000061914,5115.0,37.16460611832735,0.0,13,15,17,18,22,26,35,41,67,67,67 +GET,GET /prompts/{project}/{name} [hot],879,0,12,13.983361660580991,4.821799986530095,63.53959999978542,425.0,62.70189784646782,0.0,12,15,17,18,22,25,33,47,64,64,64 +GET,GET /prompts/{project}/{name}/versions [hot],359,0,14,14.848758773793906,5.094399995869026,71.00860000355169,272.0,25.60862494525819,0.0,14,17,18,19,23,27,40,55,71,71,71 +POST,POST /optimize [hot],107,0,20,22.10329345859387,8.263099996838719,70.58880000840873,941.0,7.632654231595059,0.0,20,24,26,29,35,44,56,56,71,71,71 +POST,POST /prompts [seed],12,0,12,11.844425006226325,10.343900008592755,15.106300008483231,34.0,0.85599860541253,0.0,12,12,12,12,12,15,15,15,15,15,15 +PUT,PUT /optimize/config,1,0,23.12060000258498,23.12060000258498,23.12060000258498,23.12060000258498,507.0,0.07133321711771083,0.0,23,23,23,23,23,23,23,23,23,23,23 +,Aggregated,2613,0,13,14.648941943761983,4.821799986530095,71.00860000355169,2407.9272866437045,186.39369632857841,0.0,13,16,18,19,23,28,37,48,67,71,71 diff --git a/loadtests/results/db_compare/postgres_sync/cache/u20_stats_history.csv b/loadtests/results/db_compare/postgres_sync/cache/u20_stats_history.csv new file mode 100644 index 0000000..b6cd80e --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168238,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168239,10,,Aggregated,0.000000,0.000000,10,11,12,18,31,38,41,44,44,44,44,95,0,10,13.654451579848109,5.731800018111244,44.31800000020303,2011.1052631578948 +1779168240,20,,Aggregated,0.000000,0.000000,12,17,20,22,26,38,45,49,56,56,56,282,0,12,15.676169859221476,4.904899979010224,55.63219997566193,2251.5921985815603 +1779168241,20,,Aggregated,72.000000,0.000000,14,18,20,23,30,40,56,63,67,67,67,478,0,14,16.812113389274433,4.904899979010224,66.98730000061914,2244.9476987447697 +1779168242,20,,Aggregated,119.500000,0.000000,13,17,20,22,26,35,49,62,67,67,67,670,0,13,16.037913730738115,4.904899979010224,66.98730000061914,2355.931343283582 +1779168243,20,,Aggregated,143.333333,0.000000,13,17,19,21,26,33,51,60,67,67,67,866,0,13,15.881598151729046,4.904899979010224,66.98730000061914,2348.2193995381062 +1779168244,20,,Aggregated,155.750000,0.000000,13,16,18,20,24,31,46,56,65,67,67,1055,0,13,15.204640662946442,4.904899979010224,66.98730000061914,2362.260663507109 +1779168245,20,,Aggregated,162.800000,0.000000,13,16,18,20,24,31,43,56,65,67,67,1253,0,13,15.018776695439866,4.904899979010224,66.98730000061914,2368.3830806065444 +1779168246,20,,Aggregated,168.333333,0.000000,13,16,18,19,24,30,41,55,65,67,67,1447,0,13,14.853475189504858,4.904899979010224,66.98730000061914,2323.5017277125085 +1779168247,20,,Aggregated,171.857143,0.000000,13,16,18,19,24,29,39,53,65,67,67,1642,0,13,14.814233982435855,4.904899979010224,66.98730000061914,2334.5505481120586 +1779168248,20,,Aggregated,174.750000,0.000000,13,16,18,19,23,28,37,49,65,67,67,1824,0,13,14.66668881543579,4.904899979010224,66.98730000061914,2361.1672149122805 diff --git a/loadtests/results/db_compare/postgres_sync/cache/u40_exceptions.csv b/loadtests/results/db_compare/postgres_sync/cache/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/cache/u40_failures.csv b/loadtests/results/db_compare/postgres_sync/cache/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/cache/u40_stats.csv b/loadtests/results/db_compare/postgres_sync/cache/u40_stats.csv new file mode 100644 index 0000000..17c2333 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u40_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts [hot],903,0,52,49.306912736370045,5.281599995214492,149.2558000027202,4161.0,64.38133252071118,0.0,52,57,61,65,71,83,92,110,150,150,150 +GET,GET /prompts/search [hot],660,0,53,51.5982209092313,5.686499993316829,129.93100000312552,5115.0,47.05612343706465,0.0,53,58,62,65,76,86,110,120,130,130,130 +GET,GET /prompts/{project}/{name} [hot],1175,0,51,48.95860587259101,5.002299993066117,134.5984000072349,425.0,83.77415914931964,0.0,51,57,61,64,71,80,88,110,130,130,130 +GET,GET /prompts/{project}/{name}/versions [hot],468,0,50,48.61712970103746,5.96199999563396,149.82839999720454,272.0,33.36706934628221,0.0,50,56,59,62,71,81,89,100,150,150,150 +POST,POST /optimize [hot],198,0,78,77.1057207059148,13.282200001413003,187.4878000235185,941.0,14.116837031119395,0.0,78,85,90,96,110,120,130,160,190,190,190 +POST,POST /prompts [seed],12,0,11,11.35986666001069,9.8260999948252,12.597399996593595,34.0,0.8555658806739027,0.0,12,12,12,12,12,13,13,13,13,13,13 +PUT,PUT /optimize/config,1,0,21.73510001739487,21.73510001739487,21.73510001739487,21.73510001739487,507.0,0.07129715672282523,0.0,22,22,22,22,22,22,22,22,22,22,22 +,Aggregated,3417,0,52,51.00472133483273,5.002299993066117,187.4878000235185,2325.779045946737,243.6223845218938,0.0,52,58,63,66,77,87,110,120,150,190,190 diff --git a/loadtests/results/db_compare/postgres_sync/cache/u40_stats_history.csv b/loadtests/results/db_compare/postgres_sync/cache/u40_stats_history.csv new file mode 100644 index 0000000..4faa056 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/cache/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168254,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168255,10,,Aggregated,0.000000,0.000000,11,12,13,14,34,39,45,46,46,46,46,94,0,11,13.406379787409877,5.902000004425645,45.9938999847509,2246.978723404255 +1779168256,30,,Aggregated,0.000000,0.000000,12,13,15,16,22,45,60,65,71,71,71,297,0,12,14.759792255430549,5.002299993066117,71.44030000199564,2393.7508417508416 +1779168257,40,,Aggregated,27.000000,0.000000,14,19,23,26,42,54,67,71,91,91,91,544,0,14,19.777961213549133,5.002299993066117,90.58060002280399,2321.3400735294117 +1779168258,40,,Aggregated,73.500000,0.000000,21,43,54,59,77,85,92,110,120,120,120,787,0,21,33.95204841171266,5.002299993066117,119.12110002595,2327.848792884371 +1779168259,40,,Aggregated,121.666667,0.000000,41,54,58,61,74,83,89,97,120,120,120,1061,0,41,39.31092686175863,5.002299993066117,119.12110002595,2272.005655042413 +1779168260,40,,Aggregated,152.250000,0.000000,45,54,58,61,72,81,89,96,120,120,120,1329,0,45,41.77305349903889,5.002299993066117,119.12110002595,2263.319036869827 +1779168261,40,,Aggregated,171.000000,0.000000,49,56,61,65,78,88,110,120,150,190,190,1572,0,49,46.19013282457667,5.002299993066117,187.4878000235185,2292.959923664122 +1779168262,40,,Aggregated,188.333333,0.000000,49,56,60,64,76,86,110,120,150,190,190,1847,0,49,46.985353221683816,5.002299993066117,187.4878000235185,2307.0129940443962 +1779168263,40,,Aggregated,198.428571,0.000000,50,57,62,65,77,86,110,120,150,190,190,2106,0,50,48.208354796049235,5.002299993066117,187.4878000235185,2309.9525166191834 +1779168264,40,,Aggregated,205.375000,0.000000,51,57,63,65,75,85,100,120,150,190,190,2373,0,51,48.96253940192696,5.002299993066117,187.4878000235185,2287.8782132321953 diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u10_exceptions.csv b/loadtests/results/db_compare/postgres_sync/mixed/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u10_failures.csv b/loadtests/results/db_compare/postgres_sync/mixed/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u10_stats.csv b/loadtests/results/db_compare/postgres_sync/mixed/u10_stats.csv new file mode 100644 index 0000000..66f3562 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u10_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts,293,0,11,20.904233788935557,5.818600009661168,110.93650001566857,9363.235494880546,21.001497666789014,0.0,11,17,27,36,55,63,76,110,110,110,110 +GET,GET /prompts/search,82,0,12,31.18209390249387,6.816200009780005,86.88519999850541,14382.682926829268,5.877552248043342,0.0,14,41,56,62,71,79,86,87,87,87,87 +POST,POST /optimize,4,0,11,1783.047599994461,10.568900004727766,7098.804699984612,941.0,0.2867098657582118,0.0,12,12,7100,7100,7100,7100,7100,7100,7100,7100,7100 +POST,POST /prompts,37,0,33,39.127505404836924,24.955000000772998,108.66590001387522,400.0,2.652066258263459,0.0,33,41,42,47,59,93,110,110,110,110,110 +POST,POST /prompts [seed],12,0,23,24.107475001073908,20.79290000256151,30.97600000910461,425.1666666666667,0.8601295972746354,0.0,24,25,25,25,27,31,31,31,31,31,31 +PUT,PUT /optimize/config,1,0,34.99559999909252,34.99559999909252,34.99559999909252,34.99559999909252,507.0,0.07167746643955294,0.0,35,35,35,35,35,35,35,35,35,35,35 +PUT,PUT /prompts/{project}/{name},37,0,40,39.47022161997125,25.377700017997995,60.84809999447316,254.0,2.652066258263459,0.0,40,40,43,45,51,59,61,61,61,61,61 +,Aggregated,466,0,14,40.87223991431404,5.818600009661168,7098.804699984612,8490.083690987125,33.40169936083167,0.0,15,31,38,41,59,67,85,110,7100,7100,7100 diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u10_stats_history.csv b/loadtests/results/db_compare/postgres_sync/mixed/u10_stats_history.csv new file mode 100644 index 0000000..9ec4aed --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168172,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168173,10,,Aggregated,0.000000,0.000000,27,35,59,86,110,110,110,110,110,110,110,39,0,27,41.44366666785059,6.262499984586611,110.93650001566857,4565.179487179487 +1779168174,10,,Aggregated,0.000000,0.000000,25,33,38,43,86,110,110,110,110,110,110,73,0,25,32.30357123299642,6.262499984586611,110.93650001566857,5957.328767123287 +1779168175,10,,Aggregated,14.000000,0.000000,27,36,42,49,61,93,110,110,110,110,110,107,0,27,32.991578505444124,6.262499984586611,110.93650001566857,6047.878504672897 +1779168176,10,,Aggregated,25.500000,0.000000,27,36,41,49,59,87,110,110,110,110,110,139,0,27,31.99913525194258,6.262499984586611,110.93650001566857,6652.884892086331 +1779168177,10,,Aggregated,28.000000,0.000000,25,35,41,47,58,78,110,110,110,110,110,171,0,25,30.439258480243037,6.262499984586611,110.93650001566857,7017.140350877193 +1779168178,10,,Aggregated,29.250000,0.000000,22,33,40,44,58,67,110,110,110,110,110,204,0,21,28.400831863252154,6.262499984586611,110.93650001566857,7348.441176470588 +1779168179,10,,Aggregated,31.000000,0.000000,22,35,41,47,61,75,110,110,110,110,110,235,0,22,29.139490213066814,6.262499984586611,110.93650001566857,7524.842553191489 +1779168180,10,,Aggregated,30.166667,0.000000,23,35,41,48,61,75,110,110,7100,7100,7100,272,0,22,55.051973161479346,6.262499984586611,7098.804699984612,7630.941176470588 +1779168181,10,,Aggregated,31.000000,0.000000,21,34,40,45,61,72,93,110,7100,7100,7100,305,0,21,50.948916721161545,6.262499984586611,7098.804699984612,7789.390163934426 +1779168182,10,,Aggregated,31.000000,0.000000,21,33,40,44,61,70,93,110,7100,7100,7100,334,0,21,48.65136856288498,5.818600009661168,7098.804699984612,7868.961077844311 diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u20_exceptions.csv b/loadtests/results/db_compare/postgres_sync/mixed/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u20_failures.csv b/loadtests/results/db_compare/postgres_sync/mixed/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u20_stats.csv b/loadtests/results/db_compare/postgres_sync/mixed/u20_stats.csv new file mode 100644 index 0000000..6b2ebdc --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u20_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts,478,0,28,37.76543954032703,5.971399979898706,179.41630000132136,9162.882845188284,33.974074652676016,0.0,29,46,57,66,86,100,120,140,180,180,180 +GET,GET /prompts/search,131,0,120,131.13877404448291,6.1138000164646655,494.2831000080332,39278.86259541985,9.3108865679928,0.0,120,180,210,220,290,330,350,380,490,490,490 +POST,POST /optimize,14,0,23,46.351171435422394,10.84529998479411,140.56249998975545,941.0,0.99505657978549,0.0,46,56,76,85,87,140,140,140,140,140,140 +POST,POST /prompts,83,0,44,55.541806026075754,24.128399993060157,174.25209999782965,400.0,5.899264008728262,0.0,44,59,71,79,100,110,140,170,170,170,170 +POST,POST /prompts [seed],12,0,11,11.336458332759017,10.302500013494864,12.675400008447468,34.0,0.8529056398161343,0.0,11,11,12,12,12,13,13,13,13,13,13 +PUT,PUT /optimize/config,1,0,21.527300006709993,21.527300006709993,21.527300006709993,21.527300006709993,507.0,0.07107546998467786,0.0,22,22,22,22,22,22,22,22,22,22,22 +PUT,PUT /prompts/{project}/{name},83,0,60,68.34966747240298,25.31600001384504,171.13380000228062,254.0,5.899264008728262,0.0,60,78,92,96,110,140,160,170,170,170,170 +,Aggregated,802,0,39,57.75627556181178,5.971399979898706,494.2831000080332,11962.294264339153,57.00252692771164,0.0,39,59,76,87,130,180,270,320,490,490,490 diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u20_stats_history.csv b/loadtests/results/db_compare/postgres_sync/mixed/u20_stats_history.csv new file mode 100644 index 0000000..978e066 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168188,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168189,10,,Aggregated,0.000000,0.000000,22,27,35,38,44,52,75,75,75,75,75,48,0,16,23.58785000372639,6.710599991492927,75.42649999959394,7246.041666666667 +1779168190,20,,Aggregated,0.000000,0.000000,32,41,56,66,85,93,130,130,150,150,150,108,0,32,38.79194074397674,6.282099988311529,148.23819999583066,8082.351851851852 +1779168191,20,,Aggregated,10.000000,0.000000,35,45,56,67,86,100,150,150,160,160,160,171,0,35,41.01317368630914,5.971399979898706,164.27149999071844,8905.888888888889 +1779168192,20,,Aggregated,30.000000,0.000000,30,41,52,59,84,98,130,150,160,160,160,228,0,30,37.38699912457633,5.971399979898706,164.27149999071844,9174.11403508772 +1779168193,20,,Aggregated,37.666667,0.000000,34,48,57,71,93,120,160,180,200,200,200,285,0,34,42.654256844023045,5.971399979898706,201.13289999426343,9884.035087719298 +1779168194,20,,Aggregated,43.500000,0.000000,34,49,59,71,94,120,160,180,200,200,200,347,0,34,43.198893949797416,5.971399979898706,201.13289999426343,9967.700288184438 +1779168195,20,,Aggregated,46.400000,0.000000,37,54,71,82,110,150,180,200,230,230,230,402,0,37,48.73383308629637,5.971399979898706,231.649399996968,10245.333333333334 +1779168196,20,,Aggregated,48.333333,0.000000,37,55,70,81,110,150,180,210,240,240,240,461,0,37,49.5374036891994,5.971399979898706,235.0027000065893,10516.12147505423 +1779168197,20,,Aggregated,49.857143,0.000000,37,55,71,82,110,150,200,230,280,280,280,524,0,36,50.54887271126757,5.971399979898706,276.27669999492355,10939.673664122138 +1779168198,20,,Aggregated,50.875000,0.000000,37,56,71,84,110,150,210,230,280,280,280,575,0,37,51.32270121855823,5.971399979898706,276.27669999492355,10861.184347826087 diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u40_exceptions.csv b/loadtests/results/db_compare/postgres_sync/mixed/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u40_failures.csv b/loadtests/results/db_compare/postgres_sync/mixed/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u40_stats.csv b/loadtests/results/db_compare/postgres_sync/mixed/u40_stats.csv new file mode 100644 index 0000000..095d5da --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u40_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts,510,0,190,206.4934670586737,11.12829998601228,537.7212000021245,9065.366666666667,36.32752974042119,0.0,190,230,270,310,400,440,490,510,540,540,540 +GET,GET /prompts/search,116,0,570,535.0290965552317,9.877600008621812,1276.8128000025172,73460.55172413793,8.262732254684035,0.0,570,700,760,800,1100,1100,1200,1200,1300,1300,1300 +POST,POST /optimize,31,0,270,263.5944838684443,42.908200004603714,522.9200999892782,941.0,2.208143964613837,0.0,270,310,380,380,410,510,520,520,520,520,520 +POST,POST /prompts,93,0,260,274.8058623654933,29.871800012188032,568.39790000231,400.0,6.6244318938415105,0.0,260,360,400,420,480,530,550,570,570,570,570 +POST,POST /prompts [seed],12,0,11,11.081116669326244,10.213600005954504,13.077499985229224,34.0,0.8547654056569691,0.0,11,11,11,11,11,13,13,13,13,13,13 +PUT,PUT /optimize/config,1,0,21.038300008513033,21.038300008513033,21.038300008513033,21.038300008513033,507.0,0.07123045047141409,0.0,21,21,21,21,21,21,21,21,21,21,21 +PUT,PUT /prompts/{project}/{name},88,0,230,268.5401022727919,30.524600006174296,579.8374000005424,254.0,6.26827964148444,0.0,230,310,350,390,530,560,580,580,580,580,580 +,Aggregated,851,0,210,264.264345828777,9.877600008621812,1276.8128000025172,15551.585193889541,60.617113351173394,0.0,210,280,360,400,510,690,930,1100,1300,1300,1300 diff --git a/loadtests/results/db_compare/postgres_sync/mixed/u40_stats_history.csv b/loadtests/results/db_compare/postgres_sync/mixed/u40_stats_history.csv new file mode 100644 index 0000000..fbcbb41 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/mixed/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168205,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168206,10,,Aggregated,0.000000,0.000000,31,50,61,63,76,200,210,210,210,210,210,45,0,31,44.574713334441185,9.877600008621812,208.66770000429824,18162.88888888889 +1779168207,20,,Aggregated,0.000000,0.000000,59,75,130,160,200,430,510,520,520,520,520,91,0,59,95.3634197802595,9.877600008621812,515.1751000084914,15959.054945054944 +1779168208,30,,Aggregated,39.000000,0.000000,76,120,170,190,250,380,510,520,530,530,530,146,0,75,117.93975411014861,9.877600008621812,525.399500009371,14382.59589041096 +1779168209,40,,Aggregated,41.500000,0.000000,110,190,240,330,450,520,710,740,890,890,890,221,0,110,181.02867511386575,9.877600008621812,891.7473000183236,13804.398190045249 +1779168210,40,,Aggregated,48.000000,0.000000,140,190,230,320,450,520,690,740,890,890,890,289,0,140,187.17652941255582,9.877600008621812,891.7473000183236,15731.775086505191 +1779168211,40,,Aggregated,52.250000,0.000000,160,220,280,340,440,520,700,740,890,890,890,370,0,160,201.51028756793224,9.877600008621812,891.7473000183236,15732.0 +1779168212,40,,Aggregated,57.000000,0.000000,160,220,300,350,450,540,710,740,890,890,890,418,0,160,207.18576148379054,9.877600008621812,891.7473000183236,15406.868421052632 +1779168213,40,,Aggregated,59.666667,0.000000,180,260,350,400,510,660,760,1000,1100,1100,1100,479,0,180,238.84508663937663,9.877600008621812,1053.5094999941066,15745.41127348643 +1779168214,40,,Aggregated,58.714286,0.000000,180,250,330,380,510,690,780,930,1100,1100,1100,554,0,180,240.22282346615958,9.877600008621812,1053.5094999941066,15937.21119133574 +1779168215,40,,Aggregated,57.625000,0.000000,180,240,300,370,500,680,780,930,1100,1100,1100,621,0,180,235.01182544336095,9.877600008621812,1053.5094999941066,16083.297906602254 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_exceptions.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_failures.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_stats.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_stats.csv new file mode 100644 index 0000000..898ffa1 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [cold],5,0,7400,7485.714840010041,3015.6077000137884,12547.972100001061,790.2,0.39290696393885094,0.0,7400,11000,11000,13000,13000,13000,13000,13000,13000,13000,13000 +POST,POST /prompts [seed],12,0,13,12.787200001184829,11.082199984230101,15.079400007380173,34.0,0.9429767134532423,0.0,13,13,13,13,15,15,15,15,15,15,15 +PUT,PUT /optimize/config,1,0,20.932799990987405,20.932799990987405,20.932799990987405,20.932799990987405,507.0,0.07858139278777018,0.0,21,21,21,21,21,21,21,21,21,21,21 +,Aggregated,18,0,13,2089.0529666697453,11.082199984230101,12547.972100001061,270.3333333333333,1.4144650701798633,0.0,13,15,3000,3800,11000,13000,13000,13000,13000,13000,13000 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_stats_history.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_stats_history.csv new file mode 100644 index 0000000..6b78bb1 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168320,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168321,10,,Aggregated,0.000000,0.000000,13,13,13,15,15,21,21,21,21,21,21,13,0,13,13.413784615785026,11.082199984230101,20.932799990987405,70.38461538461539 +1779168322,10,,Aggregated,0.000000,0.000000,13,13,13,15,15,21,21,21,21,21,21,13,0,13,13.413784615785026,11.082199984230101,20.932799990987405,70.38461538461539 +1779168323,10,,Aggregated,0.000000,0.000000,13,13,13,15,15,21,21,21,21,21,21,13,0,13,13.413784615785026,11.082199984230101,20.932799990987405,70.38461538461539 +1779168324,10,,Aggregated,6.500000,0.000000,13,13,15,21,3000,3800,3800,3800,3800,3800,3800,15,0,13,466.5987000024567,11.082199984230101,3808.993600017857,166.33333333333334 +1779168325,10,,Aggregated,6.500000,0.000000,13,13,15,21,3000,3800,3800,3800,3800,3800,3800,15,0,13,466.5987000024567,11.082199984230101,3808.993600017857,166.33333333333334 +1779168326,10,,Aggregated,6.500000,0.000000,13,13,15,21,3000,3800,3800,3800,3800,3800,3800,15,0,13,466.5987000024567,11.082199984230101,3808.993600017857,166.33333333333334 +1779168327,10,,Aggregated,6.500000,0.000000,13,13,15,21,3000,3800,3800,3800,3800,3800,3800,15,0,13,466.5987000024567,11.082199984230101,3808.993600017857,166.33333333333334 +1779168328,10,,Aggregated,3.000000,0.000000,13,15,21,21,3800,7400,7400,7400,7400,7400,7400,16,0,13,901.6352875023586,11.082199984230101,7427.184100000886,207.875 +1779168329,10,,Aggregated,3.000000,0.000000,13,15,21,21,3800,7400,7400,7400,7400,7400,7400,16,0,13,901.6352875023586,11.082199984230101,7427.184100000886,207.875 +1779168330,10,,Aggregated,3.000000,0.000000,13,15,21,21,3800,7400,7400,7400,7400,7400,7400,16,0,13,901.6352875023586,11.082199984230101,7427.184100000886,207.875 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_exceptions.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_failures.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_stats.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_stats.csv new file mode 100644 index 0000000..3a31fcf --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_stats.csv @@ -0,0 +1,4 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /prompts [seed],12,0,15,15.622775000034986,13.846599991666153,18.734300014330074,34.0,54.11708390185054,0.0,16,16,16,16,17,19,19,19,19,19,19 +PUT,PUT /optimize/config,1,0,31.68240000377409,31.68240000377409,31.68240000377409,31.68240000377409,507.0,4.509756991820879,0.0,32,32,32,32,32,32,32,32,32,32,32 +,Aggregated,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539,58.62684089367142,0.0,16,16,16,17,19,32,32,32,32,32,32 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_stats_history.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_stats_history.csv new file mode 100644 index 0000000..c28c793 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168337,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168338,10,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 +1779168339,20,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 +1779168340,20,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 +1779168341,20,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 +1779168342,20,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 +1779168343,20,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 +1779168344,20,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 +1779168345,20,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 +1779168346,20,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 +1779168347,20,,Aggregated,0.000000,0.000000,16,16,16,17,19,32,32,32,32,32,32,13,0,16,16.85813076955338,13.846599991666153,31.68240000377409,70.38461538461539 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_exceptions.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_failures.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_stats.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_stats.csv new file mode 100644 index 0000000..30abcb7 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_stats.csv @@ -0,0 +1,4 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /prompts [seed],12,0,16,16.483183341430657,14.865900011500344,18.15410002018325,34.0,52.36448986088959,0.0,17,17,18,18,18,18,18,18,18,18,18 +PUT,PUT /optimize/config,1,0,28.983700001845136,28.983700001845136,28.983700001845136,28.983700001845136,507.0,4.363707488407466,0.0,29,29,29,29,29,29,29,29,29,29,29 +,Aggregated,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539,56.72819734929706,0.0,17,17,18,18,18,29,29,29,29,29,29 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_stats_history.csv b/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_stats_history.csv new file mode 100644 index 0000000..a26739d --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_cold/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168353,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168354,10,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 +1779168355,20,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 +1779168356,30,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 +1779168357,40,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 +1779168358,40,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 +1779168359,40,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 +1779168360,40,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 +1779168361,40,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 +1779168362,40,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 +1779168363,40,,Aggregated,0.000000,0.000000,17,17,18,18,18,29,29,29,29,29,29,13,0,17,17.444761546077924,14.865900011500344,28.983700001845136,70.38461538461539 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_exceptions.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_failures.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_stats.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_stats.csv new file mode 100644 index 0000000..1c9a801 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [hot],277,0,12,13.518698916443546,8.390799979679286,54.88139999215491,941.0,19.757322343225336,0.0,12,13,13,14,16,20,49,53,55,55,55 +POST,POST /prompts [seed],12,0,13,13.168841668327028,10.564399999566376,18.067500001052395,34.0,0.8559128812949605,0.0,13,13,13,13,16,18,18,18,18,18,18 +PUT,PUT /optimize/config,1,0,24.38109999638982,24.38109999638982,24.38109999638982,24.38109999638982,507.0,0.0713260734412467,0.0,24,24,24,24,24,24,24,24,24,24,24 +,Aggregated,290,0,12,13.541678620245436,8.390799979679286,54.88139999215491,901.9724137931034,20.684561297961544,0.0,12,13,13,14,16,20,49,53,55,55,55 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_stats_history.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_stats_history.csv new file mode 100644 index 0000000..8d7daf5 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168271,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168272,10,,Aggregated,0.000000,0.000000,13,16,47,48,52,54,55,55,55,55,55,36,0,13,23.082874999090564,8.877199987182394,54.88139999215491,626.6111111111111 +1779168273,10,,Aggregated,0.000000,0.000000,13,14,17,24,49,53,54,55,55,55,55,53,0,13,19.771956603091105,8.877199987182394,54.88139999215491,727.4528301886793 +1779168274,10,,Aggregated,31.000000,0.000000,12,13,16,17,48,52,54,55,55,55,55,73,0,12,17.77359314959764,8.877199987182394,54.88139999215491,785.9589041095891 +1779168275,10,,Aggregated,24.000000,0.000000,12,13,15,16,47,52,54,55,55,55,55,90,0,12,16.640886665684068,8.877199987182394,54.88139999215491,815.2444444444444 +1779168276,10,,Aggregated,23.000000,0.000000,12,13,15,16,21,49,53,54,55,55,55,112,0,12,15.89324464189953,8.877199987182394,54.88139999215491,839.9464285714286 +1779168277,10,,Aggregated,21.500000,0.000000,12,13,14,15,18,49,53,54,55,55,55,132,0,12,15.368643938872442,8.877199987182394,54.88139999215491,855.2575757575758 +1779168278,10,,Aggregated,21.400000,0.000000,12,13,14,15,18,48,53,54,55,55,55,150,0,12,14.952253999266153,8.877199987182394,54.88139999215491,865.5466666666666 +1779168279,10,,Aggregated,21.166667,0.000000,12,13,14,14,17,47,52,54,55,55,55,170,0,12,14.665353528792313,8.783199999015778,54.88139999215491,874.4235294117647 +1779168280,10,,Aggregated,20.714286,0.000000,12,13,13,14,17,33,52,54,55,55,55,189,0,12,14.359303174334672,8.783199999015778,54.88139999215491,881.1164021164021 +1779168281,10,,Aggregated,20.500000,0.000000,12,13,13,14,16,24,52,53,55,55,55,208,0,12,14.143977884277074,8.783199999015778,54.88139999215491,886.5865384615385 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_exceptions.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_failures.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_stats.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_stats.csv new file mode 100644 index 0000000..fa9e90d --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [hot],521,0,12,15.613799424365613,8.534499997040257,123.71539999730885,941.0,37.144558369695574,0.0,12,13,15,15,18,47,64,84,120,120,120 +POST,POST /prompts [seed],12,0,11,11.221608338625325,10.09710002108477,12.714799988316372,34.0,0.8555368530448118,0.0,11,12,12,12,12,13,13,13,13,13,13 +PUT,PUT /optimize/config,1,0,22.143099980894476,22.143099980894476,22.143099980894476,22.143099980894476,507.0,0.07129473775373431,0.0,22,22,22,22,22,22,22,22,22,22,22 +,Aggregated,534,0,12,15.527325655690792,8.534499997040257,123.71539999730885,919.8052434456929,38.07138996049412,0.0,12,13,14,15,18,47,64,84,120,120,120 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_stats_history.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_stats_history.csv new file mode 100644 index 0000000..035cd1f --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168287,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168288,10,,Aggregated,0.000000,0.000000,12,18,46,48,53,54,55,55,55,55,55,34,0,12,23.14673235249596,10.085600020829588,54.52320000040345,608.1176470588235 +1779168289,20,,Aggregated,0.000000,0.000000,13,18,46,53,56,59,64,66,66,66,66,78,0,13,23.94863076803561,8.81689999368973,66.02769999881275,795.8974358974359 +1779168290,20,,Aggregated,23.000000,0.000000,12,14,17,22,54,57,64,64,66,66,66,115,0,12,19.955096521374323,8.81689999368973,66.02769999881275,842.5826086956522 +1779168291,20,,Aggregated,26.000000,0.000000,12,14,16,17,53,56,59,64,66,66,66,152,0,12,18.296442762795394,8.81689999368973,66.02769999881275,866.5394736842105 +1779168292,20,,Aggregated,30.666667,0.000000,12,15,16,18,54,64,110,120,120,120,120,194,0,12,20.854650515062044,8.534499997040257,123.71539999730885,882.659793814433 +1779168293,20,,Aggregated,32.250000,0.000000,12,14,15,17,53,59,84,110,120,120,120,229,0,12,19.480557205469662,8.534499997040257,123.71539999730885,891.5764192139738 +1779168294,20,,Aggregated,32.800000,0.000000,12,13,15,16,47,57,84,110,120,120,120,265,0,12,18.47415886817166,8.534499997040257,123.71539999730885,898.2905660377359 +1779168295,20,,Aggregated,33.833333,0.000000,12,14,15,16,37,56,79,110,120,120,120,304,0,12,17.760321381693327,8.534499997040257,123.71539999730885,903.7697368421053 +1779168296,20,,Aggregated,34.428571,0.000000,12,13,15,16,23,55,79,110,120,120,120,343,0,12,17.12616093335578,8.534499997040257,123.71539999730885,908.0029154518951 +1779168297,20,,Aggregated,34.375000,0.000000,12,13,15,16,22,54,68,110,120,120,120,380,0,12,16.75953763180204,8.534499997040257,123.71539999730885,911.2157894736843 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_exceptions.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_failures.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_stats.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_stats.csv new file mode 100644 index 0000000..61c9082 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [hot],988,0,14,18.169772875068006,8.60810000449419,81.89629999105819,941.0,70.68572057553776,0.0,14,18,21,23,30,43,57,72,82,82,82 +POST,POST /prompts [seed],12,0,12,12.285725000159195,10.80089999595657,17.558899999130517,34.0,0.8585310191360861,0.0,12,12,12,12,13,18,18,18,18,18,18 +PUT,PUT /optimize/config,1,0,21.314999990863726,21.314999990863726,21.314999990863726,21.314999990863726,507.0,0.07154425159467384,0.0,21,21,21,21,21,21,21,21,21,21,21 +,Aggregated,1001,0,14,18.102376923636328,8.60810000449419,81.89629999105819,929.6933066933067,71.61579584626851,0.0,14,18,20,23,29,42,57,70,81,82,82 diff --git a/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_stats_history.csv b/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_stats_history.csv new file mode 100644 index 0000000..0e47246 --- /dev/null +++ b/loadtests/results/db_compare/postgres_sync/optimize_hot/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779168303,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779168304,10,,Aggregated,0.000000,0.000000,12,18,72,77,79,81,82,82,82,82,82,35,0,12,30.847994284704328,10.237100010272115,81.89629999105819,617.6285714285714 +1779168305,20,,Aggregated,0.000000,0.000000,12,14,49,52,77,79,81,82,82,82,82,76,0,12,25.946009211015497,9.23480000346899,81.89629999105819,792.078947368421 +1779168306,30,,Aggregated,9.000000,0.000000,13,16,26,50,60,77,80,81,82,82,82,142,0,13,24.47440774584989,8.838899986585602,81.89629999105819,861.2957746478874 +1779168307,40,,Aggregated,24.000000,0.000000,15,21,29,39,55,68,79,80,82,82,82,225,0,15,24.43100577776527,8.838899986585602,81.89629999105819,890.6977777777778 +1779168308,40,,Aggregated,30.666667,0.000000,16,21,24,28,51,59,77,79,82,82,82,301,0,16,22.695467442346033,8.838899986585602,81.89629999105819,903.3986710963455 +1779168309,40,,Aggregated,39.250000,0.000000,15,20,23,28,49,58,77,79,82,82,82,374,0,15,21.459920321252536,8.838899986585602,81.89629999105819,910.7379679144385 +1779168310,40,,Aggregated,46.400000,0.000000,16,20,24,28,39,55,72,79,82,82,82,456,0,16,21.299076754395063,8.838899986585602,81.89629999105819,916.1798245614035 +1779168311,40,,Aggregated,52.000000,0.000000,16,20,23,26,37,52,70,78,82,82,82,529,0,16,20.51345633273761,8.770899992669001,81.89629999105819,919.6049149338374 +1779168312,40,,Aggregated,54.714286,0.000000,15,19,23,25,34,51,65,77,82,82,82,617,0,15,19.840541491226475,8.770899992669001,81.89629999105819,922.6564019448947 +1779168313,40,,Aggregated,57.750000,0.000000,15,19,22,24,32,50,64,77,82,82,82,697,0,15,19.31293945503321,8.60810000449419,81.89629999105819,924.7618364418938 diff --git a/loadtests/results/db_compare/sqlite_sync/benchmark_manifest.json b/loadtests/results/db_compare/sqlite_sync/benchmark_manifest.json new file mode 100644 index 0000000..4347589 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/benchmark_manifest.json @@ -0,0 +1,118 @@ +{ + "scenarios": [ + "mixed", + "cache", + "optimize_hot", + "optimize_cold" + ], + "results": [ + { + "scenario": "mixed", + "users": 10, + "requests_per_s": 34.28225537715882, + "failure_rate": 0.0, + "p95_ms": 140.0, + "avg_ms": 27.07073817405651, + "passed": true + }, + { + "scenario": "mixed", + "users": 20, + "requests_per_s": 43.01291650899201, + "failure_rate": 0.0, + "p95_ms": 860.0, + "avg_ms": 151.87236688699997, + "passed": false + }, + { + "scenario": "mixed", + "users": 40, + "requests_per_s": 42.82190476231978, + "failure_rate": 0.0, + "p95_ms": 810.0, + "avg_ms": 155.42963048689515, + "passed": false + }, + { + "scenario": "cache", + "users": 10, + "requests_per_s": 102.26103494334065, + "failure_rate": 0.0, + "p95_ms": 19.0, + "avg_ms": 9.175718592890522, + "passed": true + }, + { + "scenario": "cache", + "users": 20, + "requests_per_s": 189.02486623140436, + "failure_rate": 0.0, + "p95_ms": 30.0, + "avg_ms": 13.6776451054613, + "passed": true + }, + { + "scenario": "cache", + "users": 40, + "requests_per_s": 255.69884795747512, + "failure_rate": 0.0, + "p95_ms": 80.0, + "avg_ms": 45.505646179589974, + "passed": true + }, + { + "scenario": "optimize_hot", + "users": 10, + "requests_per_s": 19.496682059018685, + "failure_rate": 0.0, + "p95_ms": 19.0, + "avg_ms": 9.410179488151078, + "passed": true + }, + { + "scenario": "optimize_hot", + "users": 20, + "requests_per_s": 38.47929404147882, + "failure_rate": 0.0, + "p95_ms": 35.0, + "avg_ms": 11.60361902961384, + "passed": true + }, + { + "scenario": "optimize_hot", + "users": 40, + "requests_per_s": 72.00778418225637, + "failure_rate": 0.0, + "p95_ms": 39.0, + "avg_ms": 13.849460040169614, + "passed": true + }, + { + "scenario": "optimize_cold", + "users": 10, + "requests_per_s": 0.15956243041999693, + "failure_rate": 0.0, + "p95_ms": 12000.0, + "avg_ms": 9396.952249997412, + "passed": false + }, + { + "scenario": "optimize_cold", + "users": 20, + "requests_per_s": 0.0, + "failure_rate": 1.0, + "p95_ms": Infinity, + "avg_ms": Infinity, + "passed": false + }, + { + "scenario": "optimize_cold", + "users": 40, + "requests_per_s": 0.0, + "failure_rate": 1.0, + "p95_ms": Infinity, + "avg_ms": Infinity, + "passed": false + } + ] +} \ No newline at end of file diff --git a/loadtests/results/db_compare/sqlite_sync/benchmark_summary.md b/loadtests/results/db_compare/sqlite_sync/benchmark_summary.md new file mode 100644 index 0000000..7f8b5d0 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/benchmark_summary.md @@ -0,0 +1,16 @@ +# Benchmark Summary + +| Scenario | Users | RPS | P95 (ms) | Avg (ms) | Failure % | Pass | +| --- | ---: | ---: | ---: | ---: | ---: | :---: | +| mixed | 10 | 34.28 | 140.00 | 27.07 | 0.00 | yes | +| mixed | 20 | 43.01 | 860.00 | 151.87 | 0.00 | no | +| mixed | 40 | 42.82 | 810.00 | 155.43 | 0.00 | no | +| cache | 10 | 102.26 | 19.00 | 9.18 | 0.00 | yes | +| cache | 20 | 189.02 | 30.00 | 13.68 | 0.00 | yes | +| cache | 40 | 255.70 | 80.00 | 45.51 | 0.00 | yes | +| optimize_hot | 10 | 19.50 | 19.00 | 9.41 | 0.00 | yes | +| optimize_hot | 20 | 38.48 | 35.00 | 11.60 | 0.00 | yes | +| optimize_hot | 40 | 72.01 | 39.00 | 13.85 | 0.00 | yes | +| optimize_cold | 10 | 0.16 | 12000.00 | 9396.95 | 0.00 | no | +| optimize_cold | 20 | 0.00 | inf | inf | 100.00 | no | +| optimize_cold | 40 | 0.00 | inf | inf | 100.00 | no | diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u10_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/cache/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u10_failures.csv b/loadtests/results/db_compare/sqlite_sync/cache/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u10_stats.csv b/loadtests/results/db_compare/sqlite_sync/cache/u10_stats.csv new file mode 100644 index 0000000..d4cd6fb --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u10_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts [hot],395,0,8,9.334730126827225,3.7956999731250107,70.10040001478046,4131.0,28.128905851406376,0.0,8,9,10,11,14,19,32,67,70,70,70 +GET,GET /prompts/search [hot],308,0,8,9.20355389448028,3.786300017964095,72.36839999677613,5115.0,21.933425322109276,0.0,8,9,10,11,13,18,33,70,72,72,72 +GET,GET /prompts/{project}/{name} [hot],470,0,7,8.307415318918078,3.3100000000558794,63.333500002045184,425.0,33.46983734217974,0.0,7,8,10,11,13,16,21,44,63,63,63 +GET,GET /prompts/{project}/{name}/versions [hot],193,0,7,9.09798756426374,3.56620000093244,51.738299982389435,272.0,13.743997036256786,0.0,7,8,10,11,15,22,31,51,52,52,52 +POST,POST /optimize [hot],57,0,12,15.640212280630836,6.0477000079117715,59.36149999615736,830.0,4.0591079329877555,0.0,12,16,17,19,23,50,56,59,59,59,59 +POST,POST /prompts [seed],12,0,7,7.162500000655807,6.422599981306121,7.833900017431006,34.0,0.8545490385237381,0.0,7,7,7,7,8,8,8,8,8,8,8 +PUT,PUT /optimize/config,1,0,16.579999995883554,16.579999995883554,16.579999995883554,16.579999995883554,507.0,0.07121241987697817,0.0,17,17,17,17,17,17,17,17,17,17,17 +,Aggregated,1436,0,8,9.175718592890522,3.3100000000558794,72.36839999677613,2442.6434540389973,102.26103494334065,0.0,8,9,10,11,14,19,32,52,71,72,72 diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u10_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/cache/u10_stats_history.csv new file mode 100644 index 0000000..1316ee9 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167725,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167726,10,,Aggregated,0.000000,0.000000,8,9,9,12,43,69,71,72,72,72,72,99,0,8,13.487851515005465,3.9647999801672995,72.36839999677613,2282.4444444444443 +1779167727,10,,Aggregated,0.000000,0.000000,8,10,12,14,17,22,69,71,72,72,72,206,0,8,11.615141262260753,3.9647999801672995,72.36839999677613,2285.339805825243 +1779167728,10,,Aggregated,0.000000,0.000000,8,11,12,14,17,22,52,70,72,72,72,303,0,8,11.24791320157468,3.5189000191166997,72.36839999677613,2390.970297029703 +1779167729,10,,Aggregated,103.000000,0.000000,8,10,12,12,16,20,48,69,72,72,72,408,0,8,10.365770343495944,3.5189000191166997,72.36839999677613,2531.5612745098038 +1779167730,10,,Aggregated,101.000000,0.000000,8,10,11,12,16,21,27,67,72,72,72,504,0,8,10.118140872648274,3.5189000191166997,72.36839999677613,2501.3611111111113 +1779167731,10,,Aggregated,101.500000,0.000000,8,9,11,12,17,22,52,69,72,72,72,603,0,8,10.410380265013288,3.5189000191166997,72.36839999677613,2521.1641791044776 +1779167732,10,,Aggregated,101.500000,0.000000,8,9,11,12,16,22,50,67,72,72,72,701,0,8,10.079152924364058,3.4642000100575387,72.36839999677613,2459.7917261055636 +1779167733,10,,Aggregated,100.000000,0.000000,8,9,11,12,17,23,51,63,72,72,72,807,0,8,10.228490210511886,3.3100000000558794,72.36839999677613,2430.5675340768275 +1779167734,10,,Aggregated,100.142857,0.000000,8,9,11,12,16,23,49,62,72,72,72,910,0,7,9.927623845786522,3.3100000000558794,72.36839999677613,2455.3087912087913 +1779167735,10,,Aggregated,100.000000,0.000000,8,9,11,12,16,22,48,59,71,72,72,1017,0,8,9.736974335557415,3.3100000000558794,72.36839999677613,2447.2959685349065 diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u20_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/cache/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u20_failures.csv b/loadtests/results/db_compare/sqlite_sync/cache/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u20_stats.csv b/loadtests/results/db_compare/sqlite_sync/cache/u20_stats.csv new file mode 100644 index 0000000..a285dcf --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u20_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts [hot],710,0,11,13.312123943379307,3.4177000052295625,115.07120000896975,4131.0,50.5299905964974,0.0,11,14,16,17,23,29,46,59,120,120,120 +GET,GET /prompts/search [hot],535,0,11,13.877687475994385,3.4456999856047332,102.09890000987798,5115.0,38.07541544947339,0.0,11,14,16,18,23,30,57,65,100,100,100 +GET,GET /prompts/{project}/{name} [hot],898,0,11,13.2739191541302,3.186599991749972,107.91130000143312,425.0,63.909762754443186,0.0,11,13,16,18,23,29,54,67,110,110,110 +GET,GET /prompts/{project}/{name}/versions [hot],392,0,10,13.203553061597335,3.228000015951693,101.33440000936389,272.0,27.898248329333775,0.0,10,14,16,18,24,30,51,69,100,100,100 +POST,POST /optimize [hot],108,0,19,20.97054537093586,5.254599993349984,64.48549998458475,830.0,7.686252090734816,0.0,20,24,28,29,37,41,45,59,64,64,64 +POST,POST /prompts [seed],12,0,6,6.397250004132123,5.86409997777082,7.460799999535084,34.0,0.8540280100816462,0.0,7,7,7,7,7,7,7,7,7,7,7 +PUT,PUT /optimize/config,1,0,14.296499983174726,14.296499983174726,14.296499983174726,14.296499983174726,507.0,0.07116900084013718,0.0,14,14,14,14,14,14,14,14,14,14,14 +,Aggregated,2656,0,11,13.6776451054613,3.186599991749972,115.07120000896975,2352.546686746988,189.02486623140436,0.0,11,14,16,18,24,30,48,64,110,120,120 diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u20_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/cache/u20_stats_history.csv new file mode 100644 index 0000000..5f49b60 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167741,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167742,10,,Aggregated,0.000000,0.000000,8,9,11,12,23,38,42,42,45,45,45,113,0,8,11.050381418266046,3.6367999855428934,45.20059999777004,2340.0442477876104 +1779167743,20,,Aggregated,0.000000,0.000000,9,12,14,16,26,41,46,56,64,64,64,311,0,9,12.644976207075365,3.228000015951693,64.48549998458475,2199.0192926045015 +1779167744,20,,Aggregated,82.000000,0.000000,10,13,15,17,22,30,45,51,64,64,64,513,0,10,12.494783236776328,3.228000015951693,64.48549998458475,2328.6140350877195 +1779167745,20,,Aggregated,127.000000,0.000000,11,14,17,19,26,38,77,100,120,120,120,684,0,11,14.790694152545566,3.228000015951693,115.07120000896975,2372.8318713450294 +1779167746,20,,Aggregated,151.666667,0.000000,11,15,18,20,27,41,68,100,120,120,120,869,0,11,15.3105699659046,3.228000015951693,115.07120000896975,2385.5074798619103 +1779167747,20,,Aggregated,157.500000,0.000000,11,14,17,19,26,36,63,89,110,120,120,1074,0,11,14.487841620483502,3.228000015951693,115.07120000896975,2373.164804469274 +1779167748,20,,Aggregated,161.200000,0.000000,11,14,16,18,25,33,58,79,110,120,120,1274,0,11,14.036921271903541,3.186599991749972,115.07120000896975,2381.3241758241757 +1779167749,20,,Aggregated,168.333333,0.000000,11,13,16,18,24,30,52,74,110,120,120,1477,0,11,13.639573189321958,3.186599991749972,115.07120000896975,2402.054163845633 +1779167750,20,,Aggregated,172.857143,0.000000,10,13,16,17,23,30,46,69,110,120,120,1686,0,10,13.408585646742688,3.186599991749972,115.07120000896975,2399.867734282325 +1779167751,20,,Aggregated,176.000000,0.000000,11,14,16,18,24,29,45,64,110,120,120,1883,0,11,13.525179394721626,3.186599991749972,115.07120000896975,2379.0791290493894 diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u40_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/cache/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u40_failures.csv b/loadtests/results/db_compare/sqlite_sync/cache/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u40_stats.csv b/loadtests/results/db_compare/sqlite_sync/cache/u40_stats.csv new file mode 100644 index 0000000..651e9e3 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u40_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts [hot],921,0,45,43.970881650560806,4.009500000393018,115.76590000186116,4131.0,65.67167846314406,0.0,45,52,57,61,68,75,90,95,120,120,120 +GET,GET /prompts/search [hot],780,0,45,43.991208333113136,3.5883999953512102,116.96899999515153,5115.0,55.61770814468226,0.0,45,51,57,60,67,74,85,99,120,120,120 +GET,GET /prompts/{project}/{name} [hot],1198,0,45,44.06405517497941,3.245300002163276,122.84259998705238,425.0,85.42309532990943,0.0,45,53,57,60,67,75,90,100,120,120,120 +GET,GET /prompts/{project}/{name}/versions [hot],473,0,45,45.40839238940437,3.6751999869011343,124.2389999970328,272.0,33.72714865696758,0.0,45,53,57,61,67,85,110,120,120,120,120 +POST,POST /optimize [hot],201,0,72,69.71132437917474,5.507299996679649,147.80840001185425,830.0,14.332255560360428,0.0,72,78,84,86,99,110,130,140,150,150,150 +POST,POST /prompts [seed],12,0,6,6.540541665647955,5.521599989151582,8.763299993006513,34.0,0.8556570483797271,0.0,6,6,7,7,8,9,9,9,9,9,9 +PUT,PUT /optimize/config,1,0,15.55229999939911,15.55229999939911,15.55229999939911,15.55229999939911,507.0,0.07130475403164392,0.0,16,16,16,16,16,16,16,16,16,16,16 +,Aggregated,3586,0,46,45.505646179589974,3.245300002163276,147.80840001185425,2398.187953151143,255.69884795747512,0.0,46,54,59,62,70,80,96,110,130,150,150 diff --git a/loadtests/results/db_compare/sqlite_sync/cache/u40_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/cache/u40_stats_history.csv new file mode 100644 index 0000000..2c780c0 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/cache/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167757,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167758,10,,Aggregated,0.000000,0.000000,8,10,13,14,20,37,40,41,41,41,41,103,0,8,10.875565049326662,3.3451000053901225,41.1319000122603,2366.757281553398 +1779167759,20,,Aggregated,0.000000,0.000000,10,12,14,15,24,37,53,55,61,61,61,295,0,10,12.513278983237411,3.245300002163276,61.38580001424998,2393.0338983050847 +1779167760,30,,Aggregated,42.000000,0.000000,14,20,23,28,36,49,54,61,81,81,81,554,0,14,18.001890433334413,3.245300002163276,80.8649999962654,2496.3483754512636 +1779167761,40,,Aggregated,88.000000,0.000000,22,34,45,51,63,71,82,90,120,120,120,831,0,22,29.400164741515905,3.245300002163276,118.49719999008812,2461.347773766546 +1779167762,40,,Aggregated,130.666667,0.000000,31,44,52,57,67,76,86,93,120,120,120,1109,0,31,34.51562091955185,3.245300002163276,118.49719999008812,2479.9972948602344 +1779167763,40,,Aggregated,164.750000,0.000000,36,49,57,60,72,80,92,96,130,140,140,1372,0,36,38.423176238895564,3.245300002163276,141.1655999836512,2447.984693877551 +1779167764,40,,Aggregated,187.800000,0.000000,40,50,56,60,69,79,90,95,130,140,140,1655,0,40,39.83942302087395,3.245300002163276,141.1655999836512,2451.9728096676736 +1779167765,40,,Aggregated,202.666667,0.000000,41,50,56,59,68,78,89,95,130,140,140,1940,0,41,40.63786855650065,3.245300002163276,141.1655999836512,2424.1608247422682 +1779167766,40,,Aggregated,210.428571,0.000000,41,50,56,59,67,77,87,93,120,140,140,2221,0,41,41.182136334803204,3.245300002163276,141.1655999836512,2417.9243583971183 +1779167767,40,,Aggregated,219.875000,0.000000,43,51,56,59,67,76,86,93,120,140,140,2492,0,43,42.14549494361528,3.245300002163276,141.1655999836512,2418.053772070626 diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u10_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u10_failures.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u10_stats.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u10_stats.csv new file mode 100644 index 0000000..abca208 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u10_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts,286,0,9,15.641774125482685,3.8896999903954566,89.4432999775745,9543.902097902097,20.341753190596314,0.0,9,15,22,25,37,47,55,64,89,89,89 +GET,GET /prompts/search,72,0,48,88.42488194245055,6.2729000055696815,393.75180000206456,88153.68055555556,5.1210008032270435,0.0,57,140,150,160,180,200,380,390,390,390,390 +POST,POST /optimize,7,0,19,18.946242859653598,7.004000013694167,41.25420001219027,830.0,0.49787507809151815,0.0,19,20,22,22,41,41,41,41,41,41,41 +POST,POST /prompts,52,0,15,18.634305769340315,9.911699977237731,111.27769999438897,400.0,3.6985005801084205,0.0,16,18,21,22,27,32,37,110,110,110,110 +POST,POST /prompts [seed],12,0,7,7.063958333067906,5.552499991608784,8.842499984893948,34.0,0.853500133871174,0.0,7,7,8,8,8,9,9,9,9,9,9 +PUT,PUT /optimize/config,1,0,13.968199986265972,13.968199986265972,13.968199986265972,13.968199986265972,507.0,0.07112501115593117,0.0,14,14,14,14,14,14,14,14,14,14,14 +PUT,PUT /prompts/{project}/{name},52,0,18,19.377184618161454,9.988300007535145,43.805699999211356,254.0,3.6985005801084205,0.0,18,21,25,25,28,38,43,44,44,44,44 +,Aggregated,482,0,12,27.07073817405651,3.8896999903954566,393.75180000206456,18915.672199170123,34.28225537715882,0.0,12,18,24,28,49,140,180,200,390,390,390 diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u10_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u10_stats_history.csv new file mode 100644 index 0000000..1feca4d --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167645,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167646,10,,Aggregated,0.000000,0.000000,8,16,26,31,38,49,51,51,51,51,51,47,0,8,16.419304256139203,4.645200009690598,50.62589998124167,8290.0 +1779167647,10,,Aggregated,0.000000,0.000000,12,21,27,32,46,55,180,200,200,200,200,86,0,12,23.65241744311873,4.645200009690598,198.8087999925483,12389.93023255814 +1779167648,10,,Aggregated,47.000000,0.000000,13,22,26,31,46,57,170,180,200,200,200,121,0,13,23.829101654364198,4.367400018963963,198.8087999925483,13269.586776859504 +1779167649,10,,Aggregated,42.500000,0.000000,13,23,30,34,55,150,330,380,390,390,390,146,0,13,32.55867602862577,4.367400018963963,393.75180000206456,15239.90410958904 +1779167650,10,,Aggregated,42.500000,0.000000,12,17,26,32,49,150,200,380,390,390,390,173,0,12,28.789215029595486,4.367400018963963,393.75180000206456,16302.196531791908 +1779167651,10,,Aggregated,36.000000,0.000000,12,18,26,32,49,150,180,330,390,390,390,212,0,12,28.46771179281619,3.8896999903954566,393.75180000206456,15605.349056603774 +1779167652,10,,Aggregated,34.600000,0.000000,12,17,23,27,47,130,180,330,390,390,390,243,0,12,26.802676955023358,3.8896999903954566,393.75180000206456,15567.176954732511 +1779167653,10,,Aggregated,34.666667,0.000000,12,18,23,28,47,130,180,330,390,390,390,279,0,12,26.68198458846211,3.8896999903954566,393.75180000206456,15845.878136200718 +1779167654,10,,Aggregated,34.285714,0.000000,11,17,23,28,46,130,170,200,390,390,390,315,0,11,26.19511079401635,3.8896999903954566,393.75180000206456,16726.457142857143 +1779167655,10,,Aggregated,34.250000,0.000000,12,17,23,28,46,130,170,200,390,390,390,349,0,12,25.99411747856319,3.8896999903954566,393.75180000206456,16972.5329512894 diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u20_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u20_failures.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u20_stats.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u20_stats.csv new file mode 100644 index 0000000..44b65f1 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u20_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts,346,0,44,95.87640838078129,5.104199983179569,638.4205999784172,8805.260115606936,24.63984952336297,0.0,44,100,140,180,250,300,460,620,640,640,640 +GET,GET /prompts/search,68,0,690,642.5574014711482,7.4071999988518655,1540.8534000162035,112000.63235294117,4.842513779158041,0.0,690,920,1100,1100,1300,1400,1500,1500,1500,1500,1500 +POST,POST /optimize,12,0,34,75.84844166801001,17.613000003620982,476.705499982927,830.0,0.8545612551455365,0.0,42,56,66,66,68,480,480,480,480,480,480 +POST,POST /prompts,83,0,48,81.49579518224976,10.423400002764538,540.8481000049505,400.0,5.910715348089961,0.0,48,65,74,81,160,300,440,540,540,540,540 +POST,POST /prompts [seed],12,0,7,6.618483329172402,5.46559999929741,8.178599993698299,34.0,0.8545612551455365,0.0,7,7,7,7,8,8,8,8,8,8,8 +PUT,PUT /optimize/config,1,0,15.890999988187104,15.890999988187104,15.890999988187104,15.890999988187104,507.0,0.07121343792879471,0.0,16,16,16,16,16,16,16,16,16,16,16 +PUT,PUT /prompts/{project}/{name},82,0,53,86.51370609607888,10.980599996400997,491.173099988373,254.0,5.839501910161166,0.0,53,72,96,110,210,300,380,490,490,490,490 +,Aggregated,604,0,52,151.87236688699997,5.104199983179569,1540.8534000162035,17760.870860927153,43.01291650899201,0.0,52,97,160,190,390,860,1100,1300,1500,1500,1500 diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u20_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u20_stats_history.csv new file mode 100644 index 0000000..1f1433b --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167661,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167662,10,,Aggregated,0.000000,0.000000,11,19,29,47,62,74,78,160,160,160,160,54,0,10,24.147127778161558,5.46559999929741,157.57680000388063,18367.666666666668 +1779167663,20,,Aggregated,0.000000,0.000000,18,31,47,58,68,100,360,470,470,470,470,110,0,16,39.95443272711286,5.219500017119572,468.1014999805484,16725.945454545454 +1779167664,20,,Aggregated,41.000000,0.000000,25,48,62,77,160,270,470,510,600,600,600,162,0,25,64.78349876609475,5.104199983179569,598.976599983871,17479.475308641977 +1779167665,20,,Aggregated,46.000000,0.000000,27,52,67,87,180,290,510,570,860,860,860,201,0,27,73.26536119434246,5.104199983179569,862.2586999845225,16834.328358208953 +1779167666,20,,Aggregated,48.333333,0.000000,38,67,120,170,270,470,690,860,1100,1100,1100,233,0,38,105.06954248904654,5.104199983179569,1095.4589999746531,17333.931330472104 +1779167667,20,,Aggregated,49.000000,0.000000,44,75,130,170,300,500,800,1100,1200,1200,1200,284,0,43,117.46278626645069,5.104199983179569,1232.650799996918,17118.014084507042 +1779167668,20,,Aggregated,44.600000,0.000000,44,75,120,160,300,510,860,1100,1200,1200,1200,332,0,43,115.86166626415412,5.104199983179569,1232.650799996918,17499.572289156626 +1779167669,20,,Aggregated,44.666667,0.000000,47,80,120,160,290,570,860,1100,1200,1200,1200,376,0,47,117.65339521228638,5.104199983179569,1232.650799996918,17017.574468085106 +1779167670,20,,Aggregated,45.428571,0.000000,48,85,130,160,300,600,870,950,1200,1200,1200,419,0,48,122.26722744588855,5.104199983179569,1232.650799996918,16884.17661097852 +1779167671,20,,Aggregated,45.375000,0.000000,48,83,130,160,300,610,910,980,1200,1200,1200,457,0,48,124.15436323811362,5.104199983179569,1232.650799996918,16952.12910284464 diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u40_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u40_failures.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u40_stats.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u40_stats.csv new file mode 100644 index 0000000..0ac6cdf --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u40_stats.csv @@ -0,0 +1,9 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +GET,GET /prompts,83,0,73,96.84102048014616,4.962699982570484,785.807099979138,8946.0,21.67206155653989,0.0,73,99,120,140,180,230,750,790,790,790,790 +GET,GET /prompts/search,25,0,230,446.76574000157416,33.33169998950325,1158.7750999897253,135428.96,6.527729384499967,0.0,230,580,860,980,1100,1100,1200,1200,1200,1200,1200 +POST,POST /optimize,5,0,120,109.51395999873057,66.55089999549091,152.93040001415648,830.0,1.3055458768999935,0.0,120,140,140,150,150,150,150,150,150,150,150 +POST,POST /prompts,21,0,98,178.42530475796335,13.365399994654581,808.7272000266239,400.0,5.483292682979973,0.0,98,120,200,200,440,760,810,810,810,810,810 +POST,POST /prompts [seed],12,0,7,7.167775002017152,5.915100016864017,8.650500007206574,34.0,3.133310104559984,0.0,7,8,8,8,8,9,9,9,9,9,9 +PUT,PUT /optimize/config,1,0,17.801000009058043,17.801000009058043,17.801000009058043,17.801000009058043,507.0,0.26110917537999867,0.0,18,18,18,18,18,18,18,18,18,18,18 +PUT,PUT /prompts/{project}/{name},17,0,61,110.89386470677495,19.043500011321157,408.41679999721237,254.0,4.4388559814599775,0.0,61,78,81,84,400,410,410,410,410,410,410 +,Aggregated,164,0,70,155.42963048689515,4.962699982570484,1158.7750999897253,25280.64024390244,42.82190476231978,0.0,73,110,150,180,410,810,1000,1100,1200,1200,1200 diff --git a/loadtests/results/db_compare/sqlite_sync/mixed/u40_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/mixed/u40_stats_history.csv new file mode 100644 index 0000000..6919b62 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/mixed/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167690,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167691,10,,Aggregated,0.000000,0.000000,19,37,58,66,98,170,230,230,230,230,230,44,0,19,40.64976590995105,4.962699982570484,225.8293000049889,33360.90909090909 +1779167692,20,,Aggregated,0.000000,0.000000,39,65,78,92,120,210,550,580,580,580,580,100,0,38,65.28722699906211,4.962699982570484,578.1772999907844,24370.51 +1779167693,30,,Aggregated,37.000000,0.000000,66,98,120,150,230,440,820,930,980,980,980,153,0,66,111.61238431292921,4.962699982570484,984.491600014735,23249.065359477125 +1779167694,40,,Aggregated,44.000000,0.000000,73,110,150,180,410,810,1000,1100,1200,1200,1200,164,0,70,155.42963048689515,4.962699982570484,1158.7750999897253,25280.64024390244 +1779167695,40,,Aggregated,44.000000,0.000000,73,110,150,180,410,810,1000,1100,1200,1200,1200,164,0,70,155.42963048689515,4.962699982570484,1158.7750999897253,25280.64024390244 +1779167696,40,,Aggregated,44.000000,0.000000,73,110,150,180,410,810,1000,1100,1200,1200,1200,164,0,70,155.42963048689515,4.962699982570484,1158.7750999897253,25280.64024390244 +1779167697,40,,Aggregated,44.000000,0.000000,73,110,150,180,410,810,1000,1100,1200,1200,1200,164,0,70,155.42963048689515,4.962699982570484,1158.7750999897253,25280.64024390244 +1779167698,40,,Aggregated,44.000000,0.000000,73,110,150,180,410,810,1000,1100,1200,1200,1200,164,0,70,155.42963048689515,4.962699982570484,1158.7750999897253,25280.64024390244 +1779167699,40,,Aggregated,44.000000,0.000000,73,110,150,180,410,810,1000,1100,1200,1200,1200,164,0,70,155.42963048689515,4.962699982570484,1158.7750999897253,25280.64024390244 +1779167700,0,,Aggregated,44.000000,0.000000,73,110,150,180,410,810,1000,1100,1200,1200,1200,164,0,70,155.42963048689515,4.962699982570484,1158.7750999897253,25280.64024390244 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_failures.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_stats.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_stats.csv new file mode 100644 index 0000000..b420765 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [cold],2,0,6400,9396.952249997412,6354.704099998344,12439.20039999648,696.5,0.15956243041999693,0.0,12000,12000,12000,12000,12000,12000,12000,12000,12000,12000,12000 +POST,POST /prompts [seed],12,0,6,6.372283331681198,5.963900010101497,7.10290001006797,34.0,0.9573745825199815,0.0,6,6,7,7,7,7,7,7,7,7,7 +PUT,PUT /optimize/config,1,0,14.091799996094778,14.091799996094778,14.091799996094778,14.091799996094778,507.0,0.07978121520999847,0.0,14,14,14,14,14,14,14,14,14,14,14 +,Aggregated,15,0,6,1258.9642466647394,5.963900010101497,12439.20039999648,153.86666666666667,1.1967182281499769,0.0,6,7,7,14,6400,12000,12000,12000,12000,12000,12000 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_stats_history.csv new file mode 100644 index 0000000..55afc10 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167823,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167824,10,,Aggregated,0.000000,0.000000,6,7,7,7,7,14,14,14,14,14,14,13,0,6,6.966092305866858,5.963900010101497,14.091799996094778,70.38461538461539 +1779167825,10,,Aggregated,0.000000,0.000000,6,7,7,7,7,14,14,14,14,14,14,13,0,6,6.966092305866858,5.963900010101497,14.091799996094778,70.38461538461539 +1779167826,10,,Aggregated,0.000000,0.000000,6,7,7,7,7,14,14,14,14,14,14,13,0,6,6.966092305866858,5.963900010101497,14.091799996094778,70.38461538461539 +1779167827,10,,Aggregated,0.000000,0.000000,6,7,7,7,7,14,14,14,14,14,14,13,0,6,6.966092305866858,5.963900010101497,14.091799996094778,70.38461538461539 +1779167828,10,,Aggregated,0.000000,0.000000,6,7,7,7,7,14,14,14,14,14,14,13,0,6,6.966092305866858,5.963900010101497,14.091799996094778,70.38461538461539 +1779167829,10,,Aggregated,0.000000,0.000000,6,7,7,7,7,14,14,14,14,14,14,13,0,6,6.966092305866858,5.963900010101497,14.091799996094778,70.38461538461539 +1779167830,10,,Aggregated,3.250000,0.000000,6,7,7,7,14,6400,6400,6400,6400,6400,6400,14,0,6,460.3759499981866,5.963900010101497,6354.704099998344,111.0 +1779167831,10,,Aggregated,3.250000,0.000000,6,7,7,7,14,6400,6400,6400,6400,6400,6400,14,0,6,460.3759499981866,5.963900010101497,6354.704099998344,111.0 +1779167832,10,,Aggregated,3.250000,0.000000,6,7,7,7,14,6400,6400,6400,6400,6400,6400,14,0,6,460.3759499981866,5.963900010101497,6354.704099998344,111.0 +1779167833,10,,Aggregated,3.250000,0.000000,6,7,7,7,14,6400,6400,6400,6400,6400,6400,14,0,6,460.3759499981866,5.963900010101497,6354.704099998344,111.0 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_failures.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_stats.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_stats.csv new file mode 100644 index 0000000..26d25ca --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_stats.csv @@ -0,0 +1,2 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +,Aggregated,0,0,0,0.0,0,0,0,0.0,0.0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_stats_history.csv new file mode 100644 index 0000000..ad372d6 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167839,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167840,10,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167841,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167842,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167843,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167844,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167845,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167846,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167847,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167848,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167849,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_failures.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_stats.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_stats.csv new file mode 100644 index 0000000..26d25ca --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_stats.csv @@ -0,0 +1,2 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +,Aggregated,0,0,0,0.0,0,0,0,0.0,0.0,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_stats_history.csv new file mode 100644 index 0000000..ff6f559 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_cold/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167856,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167857,10,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167858,20,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167859,40,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167860,40,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167861,40,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167862,40,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167863,40,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167864,40,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167865,40,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167866,40,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_failures.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_stats.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_stats.csv new file mode 100644 index 0000000..30e7e2e --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [hot],273,0,7,9.410179488151078,5.031200009398162,54.21160001424141,830.0,19.496682059018685,0.0,7,8,9,10,11,19,51,54,54,54,54 +POST,POST /prompts [seed],12,0,6,6.4353666578729944,5.8351999905426055,7.424699986586347,34.0,0.8569970135832389,0.0,6,7,7,7,7,7,7,7,7,7,7 +PUT,PUT /optimize/config,1,0,14.48129999334924,14.48129999334924,14.48129999334924,14.48129999334924,507.0,0.07141641779860324,0.0,14,14,14,14,14,14,14,14,14,14,14 +,Aggregated,286,0,7,9.303093357178565,5.031200009398162,54.21160001424141,795.472027972028,20.425095490400526,0.0,7,8,8,9,11,19,51,54,54,54,54 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_stats_history.csv new file mode 100644 index 0000000..68dc6e9 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u10_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167774,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167775,10,,Aggregated,0.000000,0.000000,7,9,43,47,52,54,54,54,54,54,54,34,0,7,18.903417645453693,5.74120000237599,54.21160001424141,539.5588235294117 +1779167776,10,,Aggregated,0.000000,0.000000,7,8,9,14,51,54,54,54,54,54,54,52,0,7,14.967853845938897,5.74120000237599,54.21160001424141,640.0961538461538 +1779167777,10,,Aggregated,30.000000,0.000000,7,8,9,9,44,52,54,54,54,54,54,72,0,7,12.807041666544844,5.74120000237599,54.21160001424141,692.8472222222222 +1779167778,10,,Aggregated,24.000000,0.000000,7,8,9,9,43,52,54,54,54,54,54,89,0,7,12.003922472012052,5.74120000237599,54.21160001424141,719.0449438202247 +1779167779,10,,Aggregated,22.666667,0.000000,7,8,9,11,14,51,54,54,54,54,54,110,0,7,11.369771820186129,5.550500005483627,54.21160001424141,740.2272727272727 +1779167780,10,,Aggregated,21.750000,0.000000,7,8,9,9,13,47,54,54,54,54,54,128,0,7,10.759218751672961,5.550500005483627,54.21160001424141,752.8515625 +1779167781,10,,Aggregated,21.000000,0.000000,7,8,9,9,13,44,54,54,54,54,54,149,0,7,10.311971813707531,5.550500005483627,54.21160001424141,763.7248322147651 +1779167782,10,,Aggregated,21.000000,0.000000,7,8,9,9,13,43,52,54,54,54,54,165,0,7,10.178932728718571,5.550500005483627,54.21160001424141,770.1515151515151 +1779167783,10,,Aggregated,20.857143,0.000000,7,8,9,9,12,21,52,54,54,54,54,184,0,7,9.924279348405948,5.550500005483627,54.21160001424141,776.3315217391304 +1779167784,10,,Aggregated,20.500000,0.000000,7,8,9,10,12,20,52,54,54,54,54,208,0,7,9.754617307967587,5.031200009398162,54.21160001424141,782.5240384615385 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_failures.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_stats.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_stats.csv new file mode 100644 index 0000000..0fa8e0b --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [hot],536,0,7,11.60361902961384,5.207100010011345,128.24120000004768,830.0,38.47929404147882,0.0,7,9,11,12,16,35,56,60,130,130,130 +POST,POST /prompts [seed],12,0,7,6.9896250060992315,6.191799999214709,8.617500017862767,34.0,0.861476732271914,0.0,7,7,7,7,7,9,9,9,9,9,9 +PUT,PUT /optimize/config,1,0,15.02800002344884,15.02800002344884,15.02800002344884,15.02800002344884,507.0,0.07178972768932616,0.0,15,15,15,15,15,15,15,15,15,15,15 +,Aggregated,549,0,7,11.50900418938007,5.207100010011345,128.24120000004768,812.0127504553734,39.41256050144006,0.0,7,9,11,12,16,35,56,60,130,130,130 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_stats_history.csv new file mode 100644 index 0000000..38be518 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u20_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167790,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167791,10,,Aggregated,0.000000,0.000000,10,16,43,48,54,55,56,56,56,56,56,34,0,9,20.8679558846223,6.191799999214709,55.55550000281073,539.5588235294117 +1779167792,20,,Aggregated,0.000000,0.000000,8,14,43,47,55,56,59,60,60,60,60,76,0,8,19.620822368826914,5.263899976853281,59.74090000381693,700.0657894736842 +1779167793,20,,Aggregated,26.000000,0.000000,8,10,13,16,49,56,58,59,60,60,60,114,0,8,15.888716666826472,5.263899976853281,59.74090000381693,743.3771929824561 +1779167794,20,,Aggregated,30.000000,0.000000,7,10,12,13,47,55,56,59,60,60,60,153,0,7,13.909397386181857,5.2204999956302345,59.74090000381693,765.4575163398692 +1779167795,20,,Aggregated,33.333333,0.000000,8,10,12,13,43,52,56,59,60,60,60,188,0,8,13.810028723457163,5.2204999956302345,59.74090000381693,777.4734042553191 +1779167796,20,,Aggregated,33.750000,0.000000,7,10,11,12,30,49,56,58,60,60,60,232,0,7,12.785263793093376,5.2204999956302345,59.74090000381693,787.4353448275862 +1779167797,20,,Aggregated,35.200000,0.000000,7,9,11,12,26,48,56,58,60,60,60,270,0,7,12.053011481529447,5.2204999956302345,59.74090000381693,793.425925925926 +1779167798,20,,Aggregated,35.166667,0.000000,7,10,11,12,16,47,55,56,60,60,60,312,0,7,11.698754807674469,5.2204999956302345,59.74090000381693,798.349358974359 +1779167799,20,,Aggregated,36.571429,0.000000,7,10,11,12,16,46,55,56,60,60,60,352,0,7,11.515046875005778,5.2204999956302345,59.74090000381693,801.9460227272727 +1779167800,20,,Aggregated,37.000000,0.000000,7,10,11,12,15,37,55,56,60,60,60,392,0,7,11.115546938823655,5.2204999956302345,59.74090000381693,804.8086734693877 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_exceptions.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_exceptions.csv new file mode 100644 index 0000000..d5d6c07 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_exceptions.csv @@ -0,0 +1 @@ +Count,Message,Traceback,Nodes diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_failures.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_failures.csv new file mode 100644 index 0000000..30c1aa8 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_failures.csv @@ -0,0 +1 @@ +Method,Name,Error,Occurrences,First Seen,Last Seen diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_stats.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_stats.csv new file mode 100644 index 0000000..9161db7 --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_stats.csv @@ -0,0 +1,5 @@ +Type,Name,Request Count,Failure Count,Median Response Time,Average Response Time,Min Response Time,Max Response Time,Average Content Size,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100% +POST,POST /optimize [hot],1011,0,10,13.849460040169614,4.978500015567988,90.1192000019364,830.0,72.00778418225637,0.0,10,13,16,17,23,39,57,66,89,90,90 +POST,POST /prompts [seed],12,0,6,6.5170166684159385,5.666600016411394,7.641800009878352,34.0,0.854691800382865,0.0,7,7,7,7,7,8,8,8,8,8,8 +PUT,PUT /optimize/config,1,0,16.47849997971207,16.47849997971207,16.47849997971207,16.47849997971207,507.0,0.07122431669857208,0.0,16,16,16,16,16,16,16,16,16,16,16 +,Aggregated,1024,0,10,13.766100391222835,4.978500015567988,90.1192000019364,820.3564453125,72.93370029933781,0.0,10,13,16,17,23,39,57,66,89,90,90 diff --git a/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_stats_history.csv b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_stats_history.csv new file mode 100644 index 0000000..a34d0bf --- /dev/null +++ b/loadtests/results/db_compare/sqlite_sync/optimize_hot/u40_stats_history.csv @@ -0,0 +1,12 @@ +Timestamp,User Count,Type,Name,Requests/s,Failures/s,50%,66%,75%,80%,90%,95%,98%,99%,99.9%,99.99%,100%,Total Request Count,Total Failure Count,Total Median Response Time,Total Average Response Time,Total Min Response Time,Total Max Response Time,Total Average Content Size +1779167806,0,,Aggregated,0.000000,0.000000,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,N/A,0,0,0,0.0,0,0,0 +1779167807,10,,Aggregated,0.000000,0.000000,7,10,41,44,51,52,53,53,53,53,53,39,0,7,17.94943846284579,5.666600016411394,53.08599999989383,576.7948717948718 +1779167808,20,,Aggregated,0.000000,0.000000,7,10,16,43,51,52,57,57,57,57,57,84,0,7,17.4479488106001,5.589500011410564,57.245600008172914,712.4404761904761 +1779167809,40,,Aggregated,25.000000,0.000000,9,15,20,39,51,56,64,66,66,66,66,155,0,9,18.452671614678337,5.589500011410564,65.71789999725297,766.2903225806451 +1779167810,40,,Aggregated,27.500000,0.000000,10,14,18,39,54,66,78,88,90,90,90,238,0,9,19.46333151319487,5.579799995757639,90.1192000019364,788.5084033613446 +1779167811,40,,Aggregated,37.000000,0.000000,10,15,19,26,51,62,75,81,90,90,90,320,0,10,18.440402187752625,5.455999984405935,90.1192000019364,799.140625 +1779167812,40,,Aggregated,47.250000,0.000000,10,14,18,22,47,57,71,81,90,90,90,394,0,10,17.1071406093965,4.978500015567988,90.1192000019364,804.9365482233502 +1779167813,40,,Aggregated,52.600000,0.000000,10,12,16,19,41,54,70,78,90,90,90,479,0,10,15.771661378469489,4.978500015567988,90.1192000019364,809.384133611691 +1779167814,40,,Aggregated,57.500000,0.000000,10,12,15,17,32,52,66,78,90,90,90,556,0,10,14.995808274277465,4.978500015567988,90.1192000019364,812.2392086330935 +1779167815,40,,Aggregated,60.285714,0.000000,10,12,15,17,27,51,65,75,90,90,90,634,0,10,14.65959574191571,4.978500015567988,90.1192000019364,814.4242902208202 +1779167816,40,,Aggregated,63.375000,0.000000,10,12,15,17,25,50,64,71,90,90,90,707,0,10,14.357309335853596,4.978500015567988,90.1192000019364,816.032531824611 diff --git a/main.py b/main.py index b1ece32..459120c 100644 --- a/main.py +++ b/main.py @@ -1,39 +1,60 @@ -import datetime -import logging -import os -import sys -import tomllib -from contextlib import asynccontextmanager from collections.abc import Iterator -from pathlib import Path -from time import perf_counter from typing import Literal -from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response -from fastapi.responses import JSONResponse +from fastapi import Depends, FastAPI, Query, Response from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from loguru import logger from sqlalchemy.orm import Session -from starlette.middleware.base import BaseHTTPMiddleware import auth as auth_service import crud -from database import SQLALCHEMY_DATABASE_URL, SessionLocal, init_database -from models import Prompt, User -from cache.shared_cache import ( - PROMPT_CACHE_PREFIX, - PROMPT_CACHE_TTL_SECONDS, - build_prompt_collection_cache_key, - build_prompt_response_cache_key, - cache_get_or_set, - clear_shared_cache, - delete_shared_cache_entry, +from app_core.lifecycle import create_lifespan, resolve_app_version, run_startup_bootstrap +from app_core.logging_config import configure_logging +from database import ( + SQLALCHEMY_DATABASE_URL, + SessionLocal, + StartupSessionLocal, + close_db_session, + init_database, ) -from optimizer.service import ( - build_optimizer_config, - list_available_models, - optimize_prompt_with_active_backend, +from middleware import ExceptionLoggingMiddleware, RequestLoggingMiddleware +from models import User +from optimizer.service import list_available_models, optimize_prompt_with_active_backend +from routes import ( + bootstrap_admin_route, + change_own_password_route, + create_project_route, + create_prompt_route, + create_user_route, + delete_project_route, + delete_prompt_route, + delete_user_route, + get_app_version_route, + get_auth_status_route, + get_me_route, + get_optimize_config_route, + get_project_route, + get_prompt_route, + get_prompt_version_route, + get_provider_models_route, + get_user_route, + list_projects_route, + list_prompts_route, + list_roles_route, + list_users_route, + list_versions_route, + login_route, + optimize_prompt_route, + refresh_auth_route, + search_prompts_route, + serve_ui_route, + update_optimize_config_route, + update_project_route, + update_prompt_route, + update_prompt_tags_route, + update_user_projects_route, + update_user_route, ) from schemas import ( AuthResponse, @@ -41,19 +62,19 @@ ChangePasswordRequest, OptimizeConfigOut, OptimizeConfigUpdate, + ProjectAccessUpdate, ProjectCreate, ProjectOut, - RoleOut, + ProjectUpdate, PromptCreate, PromptData, PromptOptimizeResponse, PromptOut, - PromptVersionOut, PromptTagsUpdate, PromptUpdate, - ProjectAccessUpdate, - ProjectUpdate, + PromptVersionOut, RefreshTokenRequest, + RoleOut, UserBootstrap, UserCreate, UserLogin, @@ -61,190 +82,36 @@ UserUpdate, ) +APP_VERSION = resolve_app_version() -def _resolve_app_version() -> str: - pyproject_path = Path(__file__).resolve().parent / "pyproject.toml" - if pyproject_path.exists(): - try: - with pyproject_path.open("rb") as fp: - pyproject = tomllib.load(fp) - version = pyproject.get("project", {}).get("version") - if isinstance(version, str) and version.strip(): - return version.strip() - except Exception: - pass - - return "0.0.0" - - -APP_VERSION = _resolve_app_version() - - -def _redact_database_url(url: str) -> str: - if "://" not in url: - return url - scheme, rest = url.split("://", 1) - if "@" not in rest: - return url - _, host_part = rest.split("@", 1) - return f"{scheme}://***@{host_part}" +__all__ = [ + "SessionLocal", + "StartupSessionLocal", + "app", + "crud", + "get_db", + "init_database", + "list_available_models", + "optimize_prompt_with_active_backend", +] def _run_startup_bootstrap() -> None: - startup_started = perf_counter() - logger.info("startup.begin") - - migrate_started = perf_counter() - init_database() - logger.info("startup.migrations.done duration_ms={:.2f}", (perf_counter() - migrate_started) * 1000) - - bootstrap_started = perf_counter() - db = SessionLocal() - try: - auth_service.maybe_bootstrap_admin(db) - finally: - db.close() - - logger.info("startup.bootstrap.done duration_ms={:.2f}", (perf_counter() - bootstrap_started) * 1000) - logger.info("startup.ready total_duration_ms={:.2f}", (perf_counter() - startup_started) * 1000) - logger.info( - "startup.health status=ready pid={} db_url={}", - os.getpid(), - _redact_database_url(SQLALCHEMY_DATABASE_URL), + run_startup_bootstrap( + SQLALCHEMY_DATABASE_URL, + init_database, + StartupSessionLocal, + auth_service.maybe_bootstrap_admin, + close_db_session, ) -@asynccontextmanager -async def lifespan(app: FastAPI): - _run_startup_bootstrap() - try: - yield - finally: - logger.info("shutdown.begin pid={}", os.getpid()) +lifespan = create_lifespan(_run_startup_bootstrap) app = FastAPI(title="Prompt Man", version=APP_VERSION, lifespan=lifespan) app.mount("/ui", StaticFiles(directory="ui"), name="ui") -CONSOLE_LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO" -SHOW_CONSOLE_SOURCE = os.getenv("SHOW_CONSOLE_SOURCE", "0").strip().lower() in {"1", "true", "yes", "on"} - - -def _console_log_format(record: dict) -> str: - def _escape_markup(value: object) -> str: - return str(value).replace("<", "\\<").replace(">", "\\>") - - message = _escape_markup(record["message"]) - source = f"{_escape_markup(record['name'])}:{_escape_markup(record['function'])}:{record['line']}" - badge = "APP " - message_color = "" - - if message.startswith("request.start"): - badge = "HTTP IN " - message_color = "" - elif message.startswith("request.end"): - badge = "HTTP OUT" - message_color = "" - elif message.startswith("request.error") or message.startswith("request.exception"): - badge = "HTTP ERR" - message_color = "" - elif message.startswith("optimize.gradient"): - badge = "GP " - message_color = "" - elif message.startswith("optimize.provider"): - badge = "LLM " - message_color = "" - elif message.startswith("optimize.config"): - badge = "CFG " - message_color = "" - elif message.startswith("logging.configured"): - badge = "BOOT " - message_color = "" - - source_part = f" {source} " if SHOW_CONSOLE_SOURCE else "" - - return ( - f"{record['time'].astimezone(datetime.timezone.utc):YYYY-MM-DD HH:mm:ss.SSS} UTC " - f"{badge} " - f"{record['level'].name:<8} " - f"{source_part}" - f"{message_color}{message}\n{{exception}}" - ) - - -def configure_logging() -> None: - os.makedirs("logs", exist_ok=True) - logger.remove() - logger.add( - sys.stderr, - level=CONSOLE_LOG_LEVEL, - enqueue=True, - backtrace=False, - diagnose=False, - colorize=True, - format=_console_log_format, - ) - logger.add( - "logs/app.log", - level="DEBUG", - rotation="10 MB", - retention="10 days", - enqueue=True, - backtrace=True, - diagnose=False, - format="{time:YYYY-MM-DD HH:mm:ss.SSS!UTC} UTC | {level} | {name}:{function}:{line} | {message}", - ) - - # Uvicorn access logs have their own formatter and make console output inconsistent - # with the application logs emitted through Loguru below. - uvicorn_access_logger = logging.getLogger("uvicorn.access") - uvicorn_access_logger.handlers.clear() - uvicorn_access_logger.propagate = False - - -class ExceptionLoggingMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[no-untyped-def] - started_at = perf_counter() - try: - return await call_next(request) - except Exception: - duration_ms = (perf_counter() - started_at) * 1000 - client = request.client.host if request.client else "unknown" - logger.exception( - "request.exception method={} path={} query={} client={} duration_ms={:.2f}", - request.method, - request.url.path, - request.url.query, - client, - duration_ms, - ) - return JSONResponse(status_code=500, content={"detail": "Internal Server Error"}) - - -class RequestLoggingMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[no-untyped-def] - start = perf_counter() - client = request.client.host if request.client else "unknown" - logger.info( - "request.start method={} path={} query={} client={}", - request.method, - request.url.path, - request.url.query, - client, - ) - - response = await call_next(request) - - duration_ms = (perf_counter() - start) * 1000 - logger.info( - "request.end method={} path={} status={} duration_ms={:.2f}", - request.method, - request.url.path, - response.status_code, - duration_ms, - ) - return response - configure_logging() logger.info("logging.configured sinks=console+file") @@ -261,122 +128,42 @@ def get_db() -> Iterator[Session]: try: yield db finally: - db.close() - - -def to_user_out(user: User) -> UserOut: - return UserOut(**auth_service.user_to_dict(user)) - - -def to_project_out(project) -> ProjectOut: # type: ignore[no-untyped-def] - return ProjectOut(id=project.id, name=project.name) - - -def get_personal_config(db: Session, current_user: User) -> dict: - config = auth_service.get_or_create_personal_config(db, current_user) - return auth_service.serialize_optimizer_config(config) - - -def allowed_projects(current_user: User) -> list[str] | None: - return auth_service.allowed_projects_for_user(current_user) - - -def normalize_utc_datetime(value: datetime.datetime | None) -> datetime.datetime | None: - if value is None: - return None - if value.tzinfo is None: - return value.replace(tzinfo=datetime.timezone.utc) - return value.astimezone(datetime.timezone.utc) - - -def to_prompt_out(db: Session, prompt: Prompt) -> PromptOut: - latest = crud.get_latest_version(db, prompt.id) - if not latest: - raise ValueError(f"No version found for prompt {prompt.id}") - return PromptOut( - name=prompt.name, - project=prompt.project, - created_at=normalize_utc_datetime(prompt.created_at), - updated_at=normalize_utc_datetime(prompt.updated_at), - created_by_username=crud.resolve_audit_username(db, prompt.created_by_ref), - updated_by_username=crud.resolve_audit_username(db, prompt.updated_by_ref), - tags=[tag.name for tag in prompt.tags], - latest_version=latest.version, - role=latest.role, - task=latest.task, - context=latest.context, - constraints=latest.constraints, - output_format=latest.output_format, - examples=latest.examples, - ) - - -def _invalidate_prompt_cache(project: str, name: str) -> None: - delete_shared_cache_entry(build_prompt_response_cache_key(project, name)) - clear_shared_cache(PROMPT_CACHE_PREFIX) - - -def to_prompt_version_out(db: Session, version) -> PromptVersionOut: # type: ignore[no-untyped-def] - return PromptVersionOut( - version=version.version, - created_at=normalize_utc_datetime(version.created_at), - created_by_username=crud.resolve_audit_username(db, version.created_by_ref), - role=version.role, - task=version.task, - context=version.context, - constraints=version.constraints, - output_format=version.output_format, - examples=version.examples, - ) + close_db_session(db) @app.get("/", include_in_schema=False) def serve_ui() -> FileResponse: - return FileResponse("ui/html/index.html") + return serve_ui_route() @app.post("/auth/bootstrap-admin", response_model=AuthResponse) -def bootstrap_admin(data: UserBootstrap, db: Session = Depends(get_db)) -> AuthResponse: - if crud.list_users(db): - raise HTTPException(409, "Users already exist") - user = auth_service.create_user_record( - db, - username=data.username, - password=data.password, - role="admin", - is_active=True, - projects=[], - ) - return AuthResponse(**auth_service.build_auth_response(user)) +def bootstrap_admin(data: UserBootstrap, db=Depends(get_db)) -> AuthResponse: # type: ignore[no-untyped-def] + return bootstrap_admin_route(data, db) @app.post("/auth/login", response_model=AuthResponse) -def login(data: UserLogin, db: Session = Depends(get_db)) -> AuthResponse: - user = auth_service.authenticate_user(db, data.username, data.password) - if not user: - raise HTTPException(401, "Invalid credentials") - return AuthResponse(**auth_service.build_auth_response(user)) +def login(data: UserLogin, db=Depends(get_db)) -> AuthResponse: # type: ignore[no-untyped-def] + return login_route(data, db) @app.post("/auth/refresh", response_model=AuthResponse) -def refresh_auth(data: RefreshTokenRequest, db: Session = Depends(get_db)) -> AuthResponse: - return AuthResponse(**auth_service.refresh_session(db, data.refresh_token)) +def refresh_auth(data: RefreshTokenRequest, db=Depends(get_db)) -> AuthResponse: # type: ignore[no-untyped-def] + return refresh_auth_route(data, db) @app.get("/auth/status", response_model=AuthStatus) -def get_auth_status(db: Session = Depends(get_db)) -> AuthStatus: - has_users = bool(crud.list_users(db)) - return AuthStatus(bootstrap_required=not has_users, has_users=has_users) +def get_auth_status(db=Depends(get_db)) -> AuthStatus: # type: ignore[no-untyped-def] + return get_auth_status_route(db) @app.get("/version") -def get_version() -> dict[str, str]: - return {"name": "prompt-man", "version": APP_VERSION} +def get_app_version() -> dict[str, str]: + return get_app_version_route(APP_VERSION) @app.get("/auth/me", response_model=UserOut) def get_me(current_user: User = Depends(auth_service.get_current_user)) -> UserOut: - return to_user_out(current_user) + return get_me_route(current_user) @app.post("/auth/me/password", status_code=204) @@ -385,125 +172,67 @@ def change_own_password( db: Session = Depends(get_db), current_user: User = Depends(auth_service.get_current_user), ) -> None: - auth_service.change_own_password( - db, - current_user, - current_password=data.current_password, - new_password=data.new_password, - ) + change_own_password_route(data, db, current_user) @app.get("/roles", response_model=list[RoleOut]) -def list_roles(db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> list[RoleOut]: - return [RoleOut(**item) for item in auth_service.list_roles_out(db)] +def list_roles(db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> list[RoleOut]: # type: ignore[no-untyped-def] + return list_roles_route(db) @app.get("/users", response_model=list[UserOut]) -def list_users(db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> list[UserOut]: - return [to_user_out(user) for user in crud.list_users(db)] +def list_users(db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> list[UserOut]: # type: ignore[no-untyped-def] + return list_users_route(db) @app.post("/users", response_model=UserOut) -def create_user(data: UserCreate, db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> UserOut: - user = auth_service.create_user_record( - db, - username=data.username, - password=data.password, - role=data.role, - is_active=data.is_active, - projects=data.projects, - ) - return to_user_out(user) +def create_user(data: UserCreate, db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> UserOut: # type: ignore[no-untyped-def] + return create_user_route(data, db) @app.get("/users/{user_id}", response_model=UserOut) -def get_user(user_id: int, db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> UserOut: - user = crud.get_user_by_id(db, user_id) - if not user: - raise HTTPException(404, "User not found") - return to_user_out(user) +def get_user(user_id: int, db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> UserOut: # type: ignore[no-untyped-def] + return get_user_route(user_id, db) @app.put("/users/{user_id}", response_model=UserOut) -def update_user(user_id: int, data: UserUpdate, db: Session = Depends(get_db), current_admin: User = Depends(auth_service.require_admin)) -> UserOut: - user = crud.get_user_by_id(db, user_id) - if not user: - raise HTTPException(404, "User not found") - if current_admin.id == user.id and data.role is not None and data.role != "admin": - raise HTTPException(400, "Admin cannot remove own admin role") - updated = auth_service.update_user_record( - db, - user, - username=data.username, - password=data.password, - role=data.role, - is_active=data.is_active, - projects=data.projects, - ) - return to_user_out(updated) +def update_user(user_id: int, data: UserUpdate, db=Depends(get_db), current_admin: User = Depends(auth_service.require_admin)) -> UserOut: # type: ignore[no-untyped-def] + return update_user_route(user_id, data, db, current_admin) @app.put("/users/{user_id}/projects", response_model=UserOut) -def update_user_projects(user_id: int, data: ProjectAccessUpdate, db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> UserOut: - user = crud.get_user_by_id(db, user_id) - if not user: - raise HTTPException(404, "User not found") - updated = crud.set_user_projects(db, user, data.projects) - return to_user_out(updated) +def update_user_projects(user_id: int, data: ProjectAccessUpdate, db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> UserOut: # type: ignore[no-untyped-def] + return update_user_projects_route(user_id, data, db) @app.delete("/users/{user_id}", status_code=204) -def delete_user(user_id: int, db: Session = Depends(get_db), current_admin: User = Depends(auth_service.require_admin)) -> Response: - user = crud.get_user_by_id(db, user_id) - if not user: - raise HTTPException(404, "User not found") - if current_admin.id == user.id: - raise HTTPException(400, "Admin cannot delete self") - crud.delete_user(db, user) - return Response(status_code=204) +def delete_user(user_id: int, db=Depends(get_db), current_admin: User = Depends(auth_service.require_admin)) -> Response: # type: ignore[no-untyped-def] + return delete_user_route(user_id, db, current_admin) @app.get("/projects", response_model=list[ProjectOut]) -def list_projects(db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> list[ProjectOut]: - return [to_project_out(project) for project in crud.list_projects(db)] +def list_projects(db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> list[ProjectOut]: # type: ignore[no-untyped-def] + return list_projects_route(db) @app.get("/projects/{project_id}", response_model=ProjectOut) -def get_project(project_id: int, db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> ProjectOut: - project = crud.get_project_by_id(db, project_id) - if not project: - raise HTTPException(404, "Project not found") - return to_project_out(project) +def get_project(project_id: int, db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> ProjectOut: # type: ignore[no-untyped-def] + return get_project_route(project_id, db) @app.post("/projects", response_model=ProjectOut) -def create_project(data: ProjectCreate, db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> ProjectOut: - try: - project = crud.create_project(db, data.name) - except ValueError as exc: - raise HTTPException(409, str(exc)) from exc - return to_project_out(project) +def create_project(data: ProjectCreate, db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> ProjectOut: # type: ignore[no-untyped-def] + return create_project_route(data, db) @app.put("/projects/{project_id}", response_model=ProjectOut) -def update_project(project_id: int, data: ProjectUpdate, db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> ProjectOut: - project = crud.get_project_by_id(db, project_id) - if not project: - raise HTTPException(404, "Project not found") - try: - updated = crud.update_project(db, project, name=data.name) - except ValueError as exc: - raise HTTPException(409, str(exc)) from exc - return to_project_out(updated) +def update_project(project_id: int, data: ProjectUpdate, db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> ProjectOut: # type: ignore[no-untyped-def] + return update_project_route(project_id, data, db) @app.delete("/projects/{project_id}", status_code=204) -def delete_project(project_id: int, db: Session = Depends(get_db), _: User = Depends(auth_service.require_admin)) -> Response: - project = crud.get_project_by_id(db, project_id) - if not project: - raise HTTPException(404, "Project not found") - crud.delete_project(db, project) - return Response(status_code=204) +def delete_project(project_id: int, db=Depends(get_db), _: User = Depends(auth_service.require_admin)) -> Response: # type: ignore[no-untyped-def] + return delete_project_route(project_id, db) @app.get("/prompts/search", response_model=list[PromptOut]) @@ -514,23 +243,7 @@ def search_prompts( db: Session = Depends(get_db), current_user: User = Depends(auth_service.get_current_user), ) -> list[PromptOut]: - if project is not None: - auth_service.ensure_project_access(current_user, project) - user_allowed_projects = allowed_projects(current_user) - cache_key = build_prompt_collection_cache_key( - route="prompts.search", - project=project, - tags=tags, - mode=mode, - allowed_projects=user_allowed_projects, - ) - - def _load_search() -> list[dict[str, object]]: - prompts = crud.search_prompts_by_tags(db, tags=tags, mode=mode, project=project, allowed_projects=user_allowed_projects) - return [to_prompt_out(db, p).model_dump(mode="json") for p in prompts] - - cached_prompts = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_search) - return [PromptOut(**prompt) for prompt in cached_prompts] + return search_prompts_route(tags, mode, project, db, current_user) @app.get("/prompts", response_model=list[PromptOut]) @@ -543,238 +256,57 @@ def list_prompts( db: Session = Depends(get_db), current_user: User = Depends(auth_service.get_current_user), ) -> list[PromptOut]: - # Convert string parameters to int safely - limit_int: int | None = None - offset_int: int | None = None - try: - if limit: - limit_int = int(limit) - if limit_int < 1: - limit_int = 1 - except (ValueError, TypeError): - limit_int = None - - try: - if offset: - offset_int = int(offset) - if offset_int < 0: - offset_int = 0 - except (ValueError, TypeError): - offset_int = None - - if project is not None: - auth_service.ensure_project_access(current_user, project) - user_allowed_projects = allowed_projects(current_user) - cache_key = build_prompt_collection_cache_key( - route="prompts.list", - project=project, - tag=tag, - limit=limit_int, - offset=offset_int, - allowed_projects=user_allowed_projects, - ) - - def _load_list() -> dict[str, object]: - total_count = crud.count_prompts(db, project=project, tag=tag, allowed_projects=user_allowed_projects) - prompts = crud.list_prompts(db, project=project, tag=tag, limit=limit_int, offset=offset_int, allowed_projects=user_allowed_projects) - return { - "total_count": total_count, - "prompts": [to_prompt_out(db, prompt).model_dump(mode="json") for prompt in prompts], - } - - cached_list = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_list) - response.headers["X-Total-Count"] = str(int(cached_list.get("total_count", 0))) - return [PromptOut(**prompt) for prompt in cached_list.get("prompts", [])] + return list_prompts_route(response, project, tag, limit, offset, db, current_user) @app.post("/prompts", response_model=PromptOut) -def create_prompt(data: PromptCreate, db: Session = Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> PromptOut: - logger.info("prompt.create name={} project={}", data.name, data.project) - auth_service.ensure_project_access(current_user, data.project) - prompt = crud.get_prompt(db, data.name, data.project, allowed_projects=allowed_projects(current_user)) - if prompt: - logger.warning("prompt.create.duplicate name={} project={}", data.name, data.project) - raise HTTPException(400, "Prompt already exists") - - try: - prompt = crud.create_prompt( - db, - data.name, - data.project, - task=data.task, - actor_id=current_user.id, - role=data.role, - context=data.context, - constraints=data.constraints, - output_format=data.output_format, - examples=data.examples, - tags=data.tags, - ) - except ValueError as exc: - logger.warning("prompt.create.duplicate_content name={} project={}", data.name, data.project) - raise HTTPException(409, str(exc)) from exc - _invalidate_prompt_cache(data.project, data.name) - return to_prompt_out(db, prompt) +def create_prompt(data: PromptCreate, db=Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> PromptOut: # type: ignore[no-untyped-def] + return create_prompt_route(data, db, current_user) @app.get("/prompts/{project}/{name}", response_model=PromptOut) -def get_prompt(project: str, name: str, db: Session = Depends(get_db), current_user: User = Depends(auth_service.get_current_user)) -> PromptOut: - auth_service.ensure_project_access(current_user, project) - cache_key = build_prompt_response_cache_key(project, name) - - def _load_prompt() -> dict[str, object]: - prompt = crud.get_prompt(db, name, project, allowed_projects=allowed_projects(current_user)) - if not prompt: - raise HTTPException(404, "Prompt not found") - return to_prompt_out(db, prompt).model_dump(mode="json") - - cached_prompt = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_prompt) - return PromptOut(**cached_prompt) +def get_prompt(project: str, name: str, db=Depends(get_db), current_user: User = Depends(auth_service.get_current_user)) -> PromptOut: # type: ignore[no-untyped-def] + return get_prompt_route(project, name, db, current_user) @app.delete("/prompts/{project}/{name}", status_code=204) -def delete_prompt(project: str, name: str, db: Session = Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> Response: - logger.info("prompt.delete project={} name={}", project, name) - auth_service.ensure_project_access(current_user, project) - prompt = crud.get_prompt(db, name, project, allowed_projects=allowed_projects(current_user)) - if not prompt: - logger.warning("prompt.delete.not_found project={} name={}", project, name) - raise HTTPException(404, "Prompt not found") - - crud.delete_prompt(db, prompt) - _invalidate_prompt_cache(project, name) - return Response(status_code=204) +def delete_prompt(project: str, name: str, db=Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> Response: # type: ignore[no-untyped-def] + return delete_prompt_route(project, name, db, current_user) @app.put("/prompts/{project}/{name}", response_model=PromptVersionOut) -def update_prompt(project: str, name: str, data: PromptUpdate, db: Session = Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> PromptVersionOut: - logger.info("prompt.update project={} name={}", project, name) - auth_service.ensure_project_access(current_user, project) - prompt = crud.get_prompt(db, name, project, allowed_projects=allowed_projects(current_user)) - if not prompt: - logger.warning("prompt.update.not_found project={} name={}", project, name) - raise HTTPException(404, "Prompt not found") - - if data.tags is not None: - crud.set_prompt_tags(db, prompt, data.tags, actor_id=current_user.id) - - try: - new_version = crud.add_version( - db, - prompt.id, - task=data.task, - actor_id=current_user.id, - role=data.role, - context=data.context, - constraints=data.constraints, - output_format=data.output_format, - examples=data.examples, - ) - except ValueError as exc: - logger.warning("prompt.update.conflict project={} name={} error={}", project, name, str(exc)) - raise HTTPException(409, str(exc)) from exc - _invalidate_prompt_cache(project, name) - return to_prompt_version_out(db, new_version) +def update_prompt(project: str, name: str, data: PromptUpdate, db=Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> PromptVersionOut: # type: ignore[no-untyped-def] + return update_prompt_route(project, name, data, db, current_user) @app.put("/prompts/{project}/{name}/tags", response_model=PromptOut) -def update_prompt_tags(project: str, name: str, data: PromptTagsUpdate, db: Session = Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> PromptOut: - auth_service.ensure_project_access(current_user, project) - prompt = crud.get_prompt(db, name, project, allowed_projects=allowed_projects(current_user)) - if not prompt: - raise HTTPException(404, "Prompt not found") - - crud.set_prompt_tags(db, prompt, data.tags, actor_id=current_user.id) - _invalidate_prompt_cache(project, name) - # Reload prompt from database to ensure all relationships are properly populated - prompt = crud.get_prompt(db, name, project, allowed_projects=allowed_projects(current_user)) - if not prompt: - raise HTTPException(404, "Prompt not found after update") - return to_prompt_out(db, prompt) +def update_prompt_tags(project: str, name: str, data: PromptTagsUpdate, db=Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> PromptOut: # type: ignore[no-untyped-def] + return update_prompt_tags_route(project, name, data, db, current_user) @app.get("/prompts/{project}/{name}/versions", response_model=list[PromptVersionOut]) -def list_versions(project: str, name: str, db: Session = Depends(get_db), current_user: User = Depends(auth_service.get_current_user)) -> list[PromptVersionOut]: - auth_service.ensure_project_access(current_user, project) - cache_key = build_prompt_collection_cache_key(route="prompts.versions", project=project, name=name) - - def _load_versions() -> list[dict[str, object]]: - prompt = crud.get_prompt(db, name, project, allowed_projects=allowed_projects(current_user)) - if not prompt: - raise HTTPException(404, "Prompt not found") - - versions = crud.list_versions(db, prompt.id) - return [to_prompt_version_out(db, version).model_dump(mode="json") for version in versions] - - cached_versions = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_versions) - return [PromptVersionOut(**version) for version in cached_versions] +def list_versions(project: str, name: str, db=Depends(get_db), current_user: User = Depends(auth_service.get_current_user)) -> list[PromptVersionOut]: # type: ignore[no-untyped-def] + return list_versions_route(project, name, db, current_user) @app.get("/prompts/{project}/{name}/versions/{version}", response_model=PromptVersionOut) -def get_version(project: str, name: str, version: int, db: Session = Depends(get_db), current_user: User = Depends(auth_service.get_current_user)) -> PromptVersionOut: - auth_service.ensure_project_access(current_user, project) - cache_key = build_prompt_collection_cache_key(route="prompts.version", project=project, name=name, version=version) - - def _load_version() -> dict[str, object]: - prompt = crud.get_prompt(db, name, project, allowed_projects=allowed_projects(current_user)) - if not prompt: - raise HTTPException(404, "Prompt not found") - - v = crud.get_specific_version(db, prompt.id, version) - if not v: - raise HTTPException(404, "Version not found") - - return to_prompt_version_out(db, v).model_dump(mode="json") - - cached_version = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_version) - return PromptVersionOut(**cached_version) +def get_prompt_version(project: str, name: str, version: int, db=Depends(get_db), current_user: User = Depends(auth_service.get_current_user)) -> PromptVersionOut: # type: ignore[no-untyped-def] + return get_prompt_version_route(project, name, version, db, current_user) @app.post("/optimize", response_model=PromptOptimizeResponse) -def optimize_prompt(data: PromptData, db: Session = Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> PromptOptimizeResponse: - logger.info("optimize.request.start") - result = optimize_prompt_with_active_backend(data.model_dump(), get_personal_config(db, current_user)) - logger.info("optimize.request.done engine={}", result.engine) - optimized_dict = result.optimized_fields - if not isinstance(optimized_dict, dict): - optimized_dict = {} - optimized = PromptData( - role=optimized_dict.get("role"), - task=optimized_dict.get("task") or "", - context=optimized_dict.get("context"), - constraints=optimized_dict.get("constraints"), - output_format=optimized_dict.get("output_format"), - examples=optimized_dict.get("examples"), - ) - return PromptOptimizeResponse( - engine=result.engine, - optimized=optimized, - optimized_markdown=result.optimized_markdown, - notes=result.notes, - elapsed_seconds=result.elapsed_seconds, - ) +def optimize_prompt(data: PromptData, db=Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> PromptOptimizeResponse: # type: ignore[no-untyped-def] + return optimize_prompt_route(data, db, current_user, optimize_prompt_with_active_backend) @app.get("/optimize/config", response_model=OptimizeConfigOut) -def get_optimize_config(db: Session = Depends(get_db), current_user: User = Depends(auth_service.get_current_user)) -> OptimizeConfigOut: - cfg = get_personal_config(db, current_user) - logger.info( - "optimize.config.get effective_llm_provider={} effective_llm_model={}", - cfg.get("effective_llm_provider"), - cfg.get("effective_llm_model"), - ) - return OptimizeConfigOut(**cfg) +def get_optimize_config(db=Depends(get_db), current_user: User = Depends(auth_service.get_current_user)) -> OptimizeConfigOut: # type: ignore[no-untyped-def] + return get_optimize_config_route(db, current_user) @app.put("/optimize/config", response_model=OptimizeConfigOut) -def update_optimize_config(data: OptimizeConfigUpdate, db: Session = Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> OptimizeConfigOut: - logger.info( - "optimize.config.update llm_provider={} llm_model={}", - data.llm_provider, - data.llm_model, - ) - cfg = auth_service.update_personal_config(db, current_user, data.model_dump()) - return OptimizeConfigOut(**cfg) +def update_optimize_config(data: OptimizeConfigUpdate, db=Depends(get_db), current_user: User = Depends(auth_service.require_write_access)) -> OptimizeConfigOut: # type: ignore[no-untyped-def] + return update_optimize_config_route(data, db, current_user) @app.get("/optimize/providers/{provider}/models", response_model=list[str]) @@ -786,12 +318,4 @@ def get_provider_models( db: Session = Depends(get_db), current_user: User = Depends(auth_service.get_current_user), ) -> list[str]: - logger.info("optimize.provider.models provider={}", provider) - models = list_available_models( - provider, - base_url=base_url, - timeout_seconds=timeout_seconds, - api_token=api_token, - config_override=get_personal_config(db, current_user), - ) - return models + return get_provider_models_route(provider, base_url, api_token, timeout_seconds, db, current_user, list_available_models) diff --git a/middleware/__init__.py b/middleware/__init__.py new file mode 100644 index 0000000..3bd4791 --- /dev/null +++ b/middleware/__init__.py @@ -0,0 +1,4 @@ +from middleware.exception_logging import ExceptionLoggingMiddleware +from middleware.request_logging import RequestLoggingMiddleware + +__all__ = ["ExceptionLoggingMiddleware", "RequestLoggingMiddleware"] diff --git a/middleware/exception_logging.py b/middleware/exception_logging.py new file mode 100644 index 0000000..6e81747 --- /dev/null +++ b/middleware/exception_logging.py @@ -0,0 +1,26 @@ +from time import perf_counter + +from fastapi import Request, Response +from fastapi.responses import JSONResponse +from loguru import logger +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + + +class ExceptionLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + started_at = perf_counter() + try: + response: Response = await call_next(request) + return response + except Exception: + duration_ms = (perf_counter() - started_at) * 1000 + client = request.client.host if request.client else "unknown" + logger.exception( + "request.exception method={} path={} query={} client={} duration_ms={:.2f}", + request.method, + request.url.path, + request.url.query, + client, + duration_ms, + ) + return JSONResponse(status_code=500, content={"detail": "Internal Server Error"}) diff --git a/middleware/request_logging.py b/middleware/request_logging.py new file mode 100644 index 0000000..5ad4f08 --- /dev/null +++ b/middleware/request_logging.py @@ -0,0 +1,30 @@ +from time import perf_counter + +from fastapi import Request, Response +from loguru import logger +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + start = perf_counter() + client = request.client.host if request.client else "unknown" + logger.info( + "request.start method={} path={} query={} client={}", + request.method, + request.url.path, + request.url.query, + client, + ) + + response: Response = await call_next(request) + + duration_ms = (perf_counter() - start) * 1000 + logger.info( + "request.end method={} path={} status={} duration_ms={:.2f}", + request.method, + request.url.path, + response.status_code, + duration_ms, + ) + return response diff --git a/models/__init__.py b/models/__init__.py index bae96ac..6dc13a2 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,13 +1,13 @@ from database import Base from models.models import ( Config, - Prompt, - PromptVersion, Project, ProjectAccess, + Prompt, + PromptVersion, Role, Tag, User, ) -__all__ = ["Base", "Config", "Prompt", "PromptVersion", "Project", "ProjectAccess", "Role", "Tag", "User"] +__all__ = ["Base", "Config", "Project", "ProjectAccess", "Prompt", "PromptVersion", "Role", "Tag", "User"] diff --git a/models/models.py b/models/models.py index 21e1dc9..c9ac426 100644 --- a/models/models.py +++ b/models/models.py @@ -1,4 +1,18 @@ -from sqlalchemy import Boolean, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, Table, Text, UniqueConstraint, func +import datetime + +from sqlalchemy import ( + Boolean, + CheckConstraint, + Column, + DateTime, + ForeignKey, + Integer, + String, + Table, + Text, + UniqueConstraint, + func, +) from sqlalchemy.orm import Mapped, relationship from database import Base @@ -47,8 +61,8 @@ class Prompt(Base): id: Mapped[int] = Column(Integer, primary_key=True, index=True) # type: ignore[assignment] name: Mapped[str] = Column(String, index=True, nullable=False) # type: ignore[assignment] project_id: Mapped[int] = Column(Integer, ForeignKey("projects.id", ondelete="CASCADE"), index=True, nullable=False) # type: ignore[assignment] - created_at: Mapped[DateTime] = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) # type: ignore[assignment] - updated_at: Mapped[DateTime] = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) # type: ignore[assignment] + created_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) # type: ignore[assignment] + updated_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) # type: ignore[assignment] created_by_id: Mapped[int | None] = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # type: ignore[assignment] updated_by_id: Mapped[int | None] = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # type: ignore[assignment] @@ -75,7 +89,7 @@ class User(Base): password_hash_encrypted: Mapped[str] = Column(Text, nullable=False) # type: ignore[assignment] role_id: Mapped[int] = Column(Integer, ForeignKey("roles.id", ondelete="RESTRICT"), nullable=False, index=True) # type: ignore[assignment] is_active: Mapped[bool] = Column(Boolean, nullable=False, default=True) # type: ignore[assignment] - password_changed_at: Mapped[DateTime | None] = Column(DateTime(timezone=True), nullable=True) # type: ignore[assignment] + password_changed_at: Mapped[datetime.datetime | None] = Column(DateTime(timezone=True), nullable=True) # type: ignore[assignment] role_ref: Mapped["Role"] = relationship("Role", back_populates="users") config: Mapped["Config | None"] = relationship("Config", back_populates="user", cascade="all, delete-orphan", uselist=False) @@ -149,7 +163,7 @@ class PromptVersion(Base): id: Mapped[int] = Column(Integer, primary_key=True, index=True) # type: ignore[assignment] prompt_id: Mapped[int] = Column(Integer, ForeignKey("prompts.id", ondelete="CASCADE")) # type: ignore[assignment] version: Mapped[int] = Column(Integer) # type: ignore[assignment] - created_at: Mapped[DateTime] = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) # type: ignore[assignment] + created_at: Mapped[datetime.datetime] = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) # type: ignore[assignment] created_by_id: Mapped[int | None] = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # type: ignore[assignment] role: Mapped[str | None] = Column(String, nullable=True) # type: ignore[assignment] task: Mapped[str] = Column(Text, nullable=False) # type: ignore[assignment] diff --git a/optimizer/__init__.py b/optimizer/__init__.py index 2ee048b..d85b20a 100644 --- a/optimizer/__init__.py +++ b/optimizer/__init__.py @@ -1,11 +1,11 @@ -from optimizer.result import OptimizationResult -from optimizer.errors import BackendOperationTimeoutError from optimizer.base import PromptOptimizerBackend +from optimizer.errors import BackendOperationTimeoutError from optimizer.leo_backend import LeoPromptOptimizerBackend +from optimizer.result import OptimizationResult __all__ = [ - "OptimizationResult", "BackendOperationTimeoutError", - "PromptOptimizerBackend", "LeoPromptOptimizerBackend", + "OptimizationResult", + "PromptOptimizerBackend", ] diff --git a/optimizer/base.py b/optimizer/base.py index 82dee28..97628c5 100644 --- a/optimizer/base.py +++ b/optimizer/base.py @@ -1,9 +1,12 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any +from typing import TYPE_CHECKING, Any -from optimizer.result import OptimizationResult +if TYPE_CHECKING: + from collections.abc import Mapping + + from optimizer.result import OptimizationResult class PromptOptimizerBackend(ABC): @@ -13,7 +16,7 @@ def name(self) -> str: raise NotImplementedError @abstractmethod - def optimize(self, fields: dict[str, str | None], config: dict[str, Any]) -> OptimizationResult: + def optimize(self, fields: Mapping[str, str | None], config: Mapping[str, Any]) -> OptimizationResult: raise NotImplementedError @abstractmethod @@ -24,6 +27,6 @@ def list_models( base_url: str | None = None, timeout_seconds: int = 5, api_token: str | None = None, - config_override: dict[str, Any] | None = None, + config_override: Mapping[str, Any] | None = None, ) -> list[str]: raise NotImplementedError diff --git a/optimizer/leo_backend.py b/optimizer/leo_backend.py index af1e785..4abf04b 100644 --- a/optimizer/leo_backend.py +++ b/optimizer/leo_backend.py @@ -4,7 +4,7 @@ import os import re import time -from typing import Any +from typing import TYPE_CHECKING, Any from urllib import error as urllib_error from urllib import request as urllib_request @@ -21,6 +21,9 @@ _run_with_timeout, ) +if TYPE_CHECKING: + from collections.abc import Mapping + class LeoPromptOptimizerBackend(PromptOptimizerBackend): @property @@ -28,7 +31,8 @@ def name(self) -> str: return "leo" def _normalize_ollama_base_url(self, base_url: str | None) -> str: - candidate = (base_url or os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434")).strip() + env_base_url = os.getenv("OLLAMA_BASE_URL") or "http://127.0.0.1:11434" + candidate = (base_url or env_base_url).strip() if not candidate: candidate = "http://127.0.0.1:11434" candidate = candidate.rstrip("/") @@ -80,7 +84,7 @@ def _pick_low_memory_ollama_model(self, models: list[str], current_model: str) - return None - def _build_provider(self, provider_name: str, api_token: str | None, base_url: str | None): + def _build_provider(self, provider_name: str, api_token: str | None, base_url: str | None) -> tuple[Any, str]: from leo_prompt_optimizer import ( AnthropicProvider, GeminiProvider, @@ -92,12 +96,12 @@ def _build_provider(self, provider_name: str, api_token: str | None, base_url: s normalized = (provider_name or "openai").strip().lower() if normalized == "openai": if self._looks_like_ollama_base_url(base_url): - key = (api_token or "ollama").strip() or "ollama" - url = self._normalize_ollama_base_url(base_url) - return OpenAIProvider(api_key=key, base_url=url), "openai-compat-ollama" + ollama_key = (api_token or "ollama").strip() or "ollama" + ollama_url = self._normalize_ollama_base_url(base_url) + return OpenAIProvider(api_key=ollama_key, base_url=ollama_url), "openai-compat-ollama" - key = (api_token or "").strip() or None - url = (base_url or "").strip() or None + key: str | None = (api_token or "").strip() or None + url: str | None = (base_url or "").strip() or None return OpenAIProvider(api_key=key, base_url=url), "openai" if normalized == "ollama": key = (api_token or "ollama").strip() or "ollama" @@ -114,11 +118,11 @@ def _build_provider(self, provider_name: str, api_token: str | None, base_url: s raise ValueError(f"Unsupported provider: {normalized}") - def optimize(self, fields: dict[str, str | None], config: dict[str, Any]) -> OptimizationResult: + def optimize(self, fields: Mapping[str, str | None], config: Mapping[str, Any]) -> OptimizationResult: from leo_prompt_optimizer import LeoOptimizer - provider_name = config["effective_llm_provider"] - model_name = config["effective_llm_model"] + provider_name = str(config["effective_llm_provider"]) + model_name = str(config["effective_llm_model"]) base_url = config.get("effective_llm_base_url") api_token = config.get("effective_llm_api_token") timeout_seconds = max(5, int(config.get("effective_llm_timeout_seconds") or 120)) @@ -235,7 +239,7 @@ def list_models( base_url: str | None = None, timeout_seconds: int = 5, api_token: str | None = None, - config_override: dict[str, Any] | None = None, + config_override: Mapping[str, Any] | None = None, ) -> list[str]: normalized = (provider or "").strip().lower() if normalized == "openai" and self._looks_like_ollama_base_url(base_url): diff --git a/optimizer/service.py b/optimizer/service.py index 6956016..c1ee7b8 100644 --- a/optimizer/service.py +++ b/optimizer/service.py @@ -4,27 +4,40 @@ import hashlib import os from threading import Lock -from typing import Any +from typing import TYPE_CHECKING, Any, cast from loguru import logger -from optimizer.result import OptimizationResult -from optimizer.errors import BackendOperationTimeoutError -from optimizer.base import PromptOptimizerBackend -from optimizer.leo_backend import LeoPromptOptimizerBackend -from optimizer.utils import ( - _normalize_text, - _build_full_prompt, - _heuristic_improve, - _extract_prefixed_section, - _build_backend_failure_note, - _parse_structured_response, - _run_with_timeout, +from cache.shared_cache import ( + OPTIMIZATION_CACHE_TTL_SECONDS, + build_optimization_cache_key, + cache_get_or_set, ) -from cache.shared_cache import OPTIMIZATION_CACHE_TTL_SECONDS, build_optimization_cache_key, cache_get_or_set +from optimizer.leo_backend import LeoPromptOptimizerBackend +from optimizer.result import OptimizationResult +from optimizer.utils import _normalize_text, _parse_structured_response + +if TYPE_CHECKING: + from collections.abc import Mapping + + from optimizer.base import PromptOptimizerBackend _runtime_config_lock = Lock() +__all__ = [ + "LeoPromptOptimizerBackend", + "OptimizationResult", + "_normalize_text", + "_parse_structured_response", + "build_optimizer_config", + "get_active_optimizer_backend", + "get_active_optimizer_backend_name", + "get_runtime_optimizer_config", + "list_available_models", + "optimize_prompt_with_active_backend", + "set_runtime_optimizer_config", +] + # --------------------------------------------------------------------------- # Token encryption utilities @@ -73,7 +86,8 @@ def _decrypt_token(encrypted_token: str | None) -> str | None: } -def build_optimizer_config(overrides: dict[str, Any] | None = None) -> dict[str, Any]: +def build_optimizer_config(overrides: Mapping[str, Any] | None = None) -> dict[str, Any]: + source: Mapping[str, Any] if overrides is None: with _runtime_config_lock: source = dict(_runtime_optimize_config) @@ -184,11 +198,11 @@ def get_active_optimizer_backend() -> PromptOptimizerBackend: return _BACKEND_REGISTRY[get_active_optimizer_backend_name()] -def optimize_prompt_with_active_backend(fields: dict[str, str | None], config_override: dict[str, Any] | None = None) -> OptimizationResult: +def optimize_prompt_with_active_backend(fields: Mapping[str, str | None], config_override: Mapping[str, Any] | None = None) -> OptimizationResult: backend = get_active_optimizer_backend() config = build_optimizer_config(config_override) cache_key = build_optimization_cache_key(fields, config, backend.name) - return cache_get_or_set(cache_key, OPTIMIZATION_CACHE_TTL_SECONDS, lambda: backend.optimize(fields, config)) + return cast("OptimizationResult", cache_get_or_set(cache_key, OPTIMIZATION_CACHE_TTL_SECONDS, lambda: backend.optimize(fields, config))) def list_available_models( @@ -197,7 +211,7 @@ def list_available_models( base_url: str | None = None, timeout_seconds: int = 5, api_token: str | None = None, - config_override: dict[str, Any] | None = None, + config_override: Mapping[str, Any] | None = None, ) -> list[str]: backend = get_active_optimizer_backend() return backend.list_models( diff --git a/pyproject.toml b/pyproject.toml index af4ef4e..c188736 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "uvicorn[standard]>=0.29.0", "sqlalchemy>=2.0.0", "alembic>=1.13.0", + "psycopg2-binary>=2.9.12", "requests>=2.33.1", "leo-prompt-optimizer>=1.0.9", "loguru>=0.7.2", @@ -18,6 +19,7 @@ dependencies = [ dev = [ "httpx>=0.27.0", # FastAPI TestClient "pytest>=8.3.0", + "pytest-cov>=5.0.0", "mypy>=1.10.0", "ruff>=0.4.0", "locust>=2.31.0", @@ -33,6 +35,7 @@ dev = [ dev = [ "httpx>=0.27.0", "pytest>=8.3.0", + "pytest-cov>=5.0.0", "mypy>=1.10.0", "ruff>=0.4.0", "locust>=2.31.0", @@ -41,40 +44,6 @@ dev = [ "types-requests>=2.33.0", ] -# --------------------------------------------------------------------------- -# Ruff — linter + formatter -# --------------------------------------------------------------------------- -[tool.ruff] -target-version = "py311" -line-length = 100 -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "UP", # pyupgrade - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "SIM", # flake8-simplify - "TC", # flake8-type-checking - "RUF", # ruff-specific rules -] -ignore = [ - "E501", # line too long — handled by formatter - "B008", # FastAPI uses Query/Depends in function signatures by design -] - -[tool.ruff.isort] -known-first-party = ["crud", "database", "models", "schemas"] - -[tool.ruff.per-file-ignores] -"tests/conftest.py" = ["E402"] - -[tool.ruff.format] -quote-style = "double" -indent-style = "space" -docstring-code-format = true - # --------------------------------------------------------------------------- # Mypy — static type checker # --------------------------------------------------------------------------- @@ -88,6 +57,8 @@ disallow_untyped_defs = true disallow_incomplete_defs = true check_untyped_defs = true no_implicit_optional = true +explicit_package_bases = true +exclude = ["^snippets/"] [[tool.mypy.overrides]] module = ["alembic.*"] @@ -96,3 +67,24 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = ["leo_prompt_optimizer.*"] ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["tests.*"] +disallow_untyped_defs = false +warn_return_any = false +warn_unused_ignores = false + +[[tool.mypy.overrides]] +module = ["app_core.*", "cache.*", "database", "middleware.*", "models", "models.*", "optimizer.*", "routes.shared"] +strict = true + +# --------------------------------------------------------------------------- +# Coverage +# --------------------------------------------------------------------------- +[tool.coverage.run] +source = ["app_core", "auth", "cache", "crud", "database", "main", "middleware", "models", "optimizer", "routes", "schemas"] +omit = ["snippets/*", "loadtests/*", "tests/*", "alembic/*"] + +[tool.coverage.report] +skip_empty = true +show_missing = false diff --git a/requirements.txt b/requirements.txt index 732b672..56b4cc4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ fastapi uvicorn sqlalchemy alembic +psycopg2-binary requests leo-prompt-optimizer loguru diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..a22fbd5 --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,69 @@ +from routes.app_version import get_app_version_route +from routes.auth_bootstrap_admin import bootstrap_admin_route +from routes.auth_change_password import change_own_password_route +from routes.auth_login import login_route +from routes.auth_me import get_me_route +from routes.auth_refresh import refresh_auth_route +from routes.auth_status import get_auth_status_route +from routes.optimize_config_get import get_optimize_config_route +from routes.optimize_config_update import update_optimize_config_route +from routes.optimize_prompt import optimize_prompt_route +from routes.optimize_provider_models import get_provider_models_route +from routes.projects_create import create_project_route +from routes.projects_delete import delete_project_route +from routes.projects_get import get_project_route +from routes.projects_list import list_projects_route +from routes.projects_update import update_project_route +from routes.prompts_create import create_prompt_route +from routes.prompts_delete import delete_prompt_route +from routes.prompts_get import get_prompt_route +from routes.prompts_list import list_prompts_route +from routes.prompts_search import search_prompts_route +from routes.prompts_update import update_prompt_route +from routes.prompts_update_tags import update_prompt_tags_route +from routes.prompts_version_get import get_prompt_version_route +from routes.prompts_versions_list import list_versions_route +from routes.roles_list import list_roles_route +from routes.serve_ui import serve_ui_route +from routes.users_create import create_user_route +from routes.users_delete import delete_user_route +from routes.users_get import get_user_route +from routes.users_list import list_users_route +from routes.users_update import update_user_route +from routes.users_update_projects import update_user_projects_route + +__all__ = [ + "bootstrap_admin_route", + "change_own_password_route", + "create_project_route", + "create_prompt_route", + "create_user_route", + "delete_project_route", + "delete_prompt_route", + "delete_user_route", + "get_app_version_route", + "get_auth_status_route", + "get_me_route", + "get_optimize_config_route", + "get_project_route", + "get_prompt_route", + "get_prompt_version_route", + "get_provider_models_route", + "get_user_route", + "list_projects_route", + "list_prompts_route", + "list_roles_route", + "list_users_route", + "list_versions_route", + "login_route", + "optimize_prompt_route", + "refresh_auth_route", + "search_prompts_route", + "serve_ui_route", + "update_optimize_config_route", + "update_project_route", + "update_prompt_route", + "update_prompt_tags_route", + "update_user_projects_route", + "update_user_route", +] diff --git a/routes/app_version.py b/routes/app_version.py new file mode 100644 index 0000000..ac38685 --- /dev/null +++ b/routes/app_version.py @@ -0,0 +1,4 @@ + + +def get_app_version_route(app_version: str) -> dict[str, str]: + return {"name": "prompt-man", "version": app_version} diff --git a/routes/auth_bootstrap_admin.py b/routes/auth_bootstrap_admin.py new file mode 100644 index 0000000..77bf3a3 --- /dev/null +++ b/routes/auth_bootstrap_admin.py @@ -0,0 +1,21 @@ +from fastapi import HTTPException + +import auth as auth_service +import crud +from database import run_db_call +from schemas import AuthResponse, UserBootstrap + + +def bootstrap_admin_route(data: UserBootstrap, db) -> AuthResponse: # type: ignore[no-untyped-def] + if run_db_call(db, crud.list_users): + raise HTTPException(409, "Users already exist") + user = run_db_call( + db, + auth_service.create_user_record, + username=data.username, + password=data.password, + role="admin", + is_active=True, + projects=[], + ) + return AuthResponse(**auth_service.build_auth_response(user)) diff --git a/routes/auth_change_password.py b/routes/auth_change_password.py new file mode 100644 index 0000000..1c546ee --- /dev/null +++ b/routes/auth_change_password.py @@ -0,0 +1,14 @@ +import auth as auth_service +from database import run_db_call +from models import User +from schemas import ChangePasswordRequest + + +def change_own_password_route(data: ChangePasswordRequest, db, current_user: User) -> None: # type: ignore[no-untyped-def] + run_db_call( + db, + auth_service.change_own_password, + current_user, + current_password=data.current_password, + new_password=data.new_password, + ) diff --git a/routes/auth_login.py b/routes/auth_login.py new file mode 100644 index 0000000..00c1bed --- /dev/null +++ b/routes/auth_login.py @@ -0,0 +1,12 @@ +from fastapi import HTTPException + +import auth as auth_service +from database import run_db_call +from schemas import AuthResponse, UserLogin + + +def login_route(data: UserLogin, db) -> AuthResponse: # type: ignore[no-untyped-def] + user = run_db_call(db, auth_service.authenticate_user, data.username, data.password) + if not user: + raise HTTPException(401, "Invalid credentials") + return AuthResponse(**auth_service.build_auth_response(user)) diff --git a/routes/auth_me.py b/routes/auth_me.py new file mode 100644 index 0000000..34407f7 --- /dev/null +++ b/routes/auth_me.py @@ -0,0 +1,7 @@ +from models import User +from routes.shared import to_user_out +from schemas import UserOut + + +def get_me_route(current_user: User) -> UserOut: + return to_user_out(current_user) diff --git a/routes/auth_refresh.py b/routes/auth_refresh.py new file mode 100644 index 0000000..00c40df --- /dev/null +++ b/routes/auth_refresh.py @@ -0,0 +1,7 @@ +import auth as auth_service +from database import run_db_call +from schemas import AuthResponse, RefreshTokenRequest + + +def refresh_auth_route(data: RefreshTokenRequest, db) -> AuthResponse: # type: ignore[no-untyped-def] + return AuthResponse(**run_db_call(db, auth_service.refresh_session, data.refresh_token)) diff --git a/routes/auth_status.py b/routes/auth_status.py new file mode 100644 index 0000000..6b703de --- /dev/null +++ b/routes/auth_status.py @@ -0,0 +1,8 @@ +import crud +from database import run_db_call +from schemas import AuthStatus + + +def get_auth_status_route(db) -> AuthStatus: # type: ignore[no-untyped-def] + has_users = bool(run_db_call(db, crud.list_users)) + return AuthStatus(bootstrap_required=not has_users, has_users=has_users) diff --git a/routes/optimize_config_get.py b/routes/optimize_config_get.py new file mode 100644 index 0000000..ce319e9 --- /dev/null +++ b/routes/optimize_config_get.py @@ -0,0 +1,14 @@ +from loguru import logger + +from routes.shared import get_personal_config +from schemas import OptimizeConfigOut + + +def get_optimize_config_route(db, current_user) -> OptimizeConfigOut: # type: ignore[no-untyped-def] + cfg = get_personal_config(db, current_user) + logger.info( + "optimize.config.get effective_llm_provider={} effective_llm_model={}", + cfg.get("effective_llm_provider"), + cfg.get("effective_llm_model"), + ) + return OptimizeConfigOut(**cfg) diff --git a/routes/optimize_config_update.py b/routes/optimize_config_update.py new file mode 100644 index 0000000..c8fb676 --- /dev/null +++ b/routes/optimize_config_update.py @@ -0,0 +1,15 @@ +from loguru import logger + +import auth as auth_service +from database import run_db_call +from schemas import OptimizeConfigOut, OptimizeConfigUpdate + + +def update_optimize_config_route(data: OptimizeConfigUpdate, db, current_user) -> OptimizeConfigOut: # type: ignore[no-untyped-def] + logger.info( + "optimize.config.update llm_provider={} llm_model={}", + data.llm_provider, + data.llm_model, + ) + cfg = run_db_call(db, auth_service.update_personal_config, current_user, data.model_dump()) + return OptimizeConfigOut(**cfg) diff --git a/routes/optimize_prompt.py b/routes/optimize_prompt.py new file mode 100644 index 0000000..4b96816 --- /dev/null +++ b/routes/optimize_prompt.py @@ -0,0 +1,28 @@ +from loguru import logger + +from routes.shared import get_personal_config +from schemas import PromptData, PromptOptimizeResponse + + +def optimize_prompt_route(data: PromptData, db, current_user, optimizer) -> PromptOptimizeResponse: # type: ignore[no-untyped-def] + logger.info("optimize.request.start") + result = optimizer(data.model_dump(), get_personal_config(db, current_user)) + logger.info("optimize.request.done engine={}", result.engine) + optimized_dict = result.optimized_fields + if not isinstance(optimized_dict, dict): + optimized_dict = {} + optimized = PromptData( + role=optimized_dict.get("role"), + task=optimized_dict.get("task") or "", + context=optimized_dict.get("context"), + constraints=optimized_dict.get("constraints"), + output_format=optimized_dict.get("output_format"), + examples=optimized_dict.get("examples"), + ) + return PromptOptimizeResponse( + engine=result.engine, + optimized=optimized, + optimized_markdown=result.optimized_markdown, + notes=result.notes, + elapsed_seconds=result.elapsed_seconds, + ) diff --git a/routes/optimize_provider_models.py b/routes/optimize_provider_models.py new file mode 100644 index 0000000..b7fa788 --- /dev/null +++ b/routes/optimize_provider_models.py @@ -0,0 +1,26 @@ +from collections.abc import Callable + +from loguru import logger +from sqlalchemy.orm import Session + +from models import User +from routes.shared import get_personal_config + + +def get_provider_models_route( + provider: str, + base_url: str | None, + api_token: str | None, + timeout_seconds: int, + db: Session, + current_user: User, + model_lister: Callable[..., list[str]], +) -> list[str]: + logger.info("optimize.provider.models provider={}", provider) + return model_lister( + provider, + base_url=base_url, + timeout_seconds=timeout_seconds, + api_token=api_token, + config_override=get_personal_config(db, current_user), + ) diff --git a/routes/projects_create.py b/routes/projects_create.py new file mode 100644 index 0000000..148f5f9 --- /dev/null +++ b/routes/projects_create.py @@ -0,0 +1,14 @@ +from fastapi import HTTPException + +import crud +from database import run_db_call +from routes.shared import to_project_out +from schemas import ProjectCreate, ProjectOut + + +def create_project_route(data: ProjectCreate, db) -> ProjectOut: # type: ignore[no-untyped-def] + try: + project = run_db_call(db, crud.create_project, data.name) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + return to_project_out(project) diff --git a/routes/projects_delete.py b/routes/projects_delete.py new file mode 100644 index 0000000..a6311f6 --- /dev/null +++ b/routes/projects_delete.py @@ -0,0 +1,12 @@ +from fastapi import HTTPException, Response + +import crud +from database import run_db_call + + +def delete_project_route(project_id: int, db) -> Response: # type: ignore[no-untyped-def] + project = run_db_call(db, crud.get_project_by_id, project_id) + if not project: + raise HTTPException(404, "Project not found") + run_db_call(db, crud.delete_project, project) + return Response(status_code=204) diff --git a/routes/projects_get.py b/routes/projects_get.py new file mode 100644 index 0000000..d2871f8 --- /dev/null +++ b/routes/projects_get.py @@ -0,0 +1,13 @@ +from fastapi import HTTPException + +import crud +from database import run_db_call +from routes.shared import to_project_out +from schemas import ProjectOut + + +def get_project_route(project_id: int, db) -> ProjectOut: # type: ignore[no-untyped-def] + project = run_db_call(db, crud.get_project_by_id, project_id) + if not project: + raise HTTPException(404, "Project not found") + return to_project_out(project) diff --git a/routes/projects_list.py b/routes/projects_list.py new file mode 100644 index 0000000..818a0ce --- /dev/null +++ b/routes/projects_list.py @@ -0,0 +1,8 @@ +import crud +from database import run_db_call +from routes.shared import to_project_out +from schemas import ProjectOut + + +def list_projects_route(db) -> list[ProjectOut]: # type: ignore[no-untyped-def] + return [to_project_out(project) for project in run_db_call(db, crud.list_projects)] diff --git a/routes/projects_update.py b/routes/projects_update.py new file mode 100644 index 0000000..8ef8875 --- /dev/null +++ b/routes/projects_update.py @@ -0,0 +1,17 @@ +from fastapi import HTTPException + +import crud +from database import run_db_call +from routes.shared import to_project_out +from schemas import ProjectOut, ProjectUpdate + + +def update_project_route(project_id: int, data: ProjectUpdate, db) -> ProjectOut: # type: ignore[no-untyped-def] + project = run_db_call(db, crud.get_project_by_id, project_id) + if not project: + raise HTTPException(404, "Project not found") + try: + updated = run_db_call(db, crud.update_project, project, name=data.name) + except ValueError as exc: + raise HTTPException(409, str(exc)) from exc + return to_project_out(updated) diff --git a/routes/prompts_create.py b/routes/prompts_create.py new file mode 100644 index 0000000..71c8f18 --- /dev/null +++ b/routes/prompts_create.py @@ -0,0 +1,42 @@ +from fastapi import HTTPException +from loguru import logger + +import auth as auth_service +import crud +from database import run_db_call +from models import User +from routes.shared import allowed_projects, invalidate_prompt_cache, to_prompt_out +from schemas import PromptCreate, PromptOut + + +def create_prompt_route(data: PromptCreate, db, current_user: User) -> PromptOut: # type: ignore[no-untyped-def] + logger.info("prompt.create name={} project={}", data.name, data.project) + auth_service.ensure_project_access(current_user, data.project) + prompt = run_db_call(db, crud.get_prompt, data.name, data.project, allowed_projects=allowed_projects(current_user)) + if prompt: + logger.warning("prompt.create.duplicate name={} project={}", data.name, data.project) + raise HTTPException(400, "Prompt already exists") + + try: + prompt = run_db_call( + db, + crud.create_prompt, + data.name, + data.project, + task=data.task, + actor_id=current_user.id, + role=data.role, + context=data.context, + constraints=data.constraints, + output_format=data.output_format, + examples=data.examples, + tags=data.tags, + ) + except ValueError as exc: + logger.warning("prompt.create.duplicate_content name={} project={}", data.name, data.project) + raise HTTPException(409, str(exc)) from exc + invalidate_prompt_cache(data.project, data.name) + prompt = run_db_call(db, crud.get_prompt, data.name, data.project, allowed_projects=allowed_projects(current_user)) + if not prompt: + raise HTTPException(404, "Prompt not found after create") + return to_prompt_out(db, prompt) diff --git a/routes/prompts_delete.py b/routes/prompts_delete.py new file mode 100644 index 0000000..fda4739 --- /dev/null +++ b/routes/prompts_delete.py @@ -0,0 +1,21 @@ +from fastapi import HTTPException, Response +from loguru import logger + +import auth as auth_service +import crud +from database import run_db_call +from models import User +from routes.shared import allowed_projects, invalidate_prompt_cache + + +def delete_prompt_route(project: str, name: str, db, current_user: User) -> Response: # type: ignore[no-untyped-def] + logger.info("prompt.delete project={} name={}", project, name) + auth_service.ensure_project_access(current_user, project) + prompt = run_db_call(db, crud.get_prompt, name, project, allowed_projects=allowed_projects(current_user)) + if not prompt: + logger.warning("prompt.delete.not_found project={} name={}", project, name) + raise HTTPException(404, "Prompt not found") + + run_db_call(db, crud.delete_prompt, prompt) + invalidate_prompt_cache(project, name) + return Response(status_code=204) diff --git a/routes/prompts_get.py b/routes/prompts_get.py new file mode 100644 index 0000000..bd37ad5 --- /dev/null +++ b/routes/prompts_get.py @@ -0,0 +1,27 @@ +from fastapi import HTTPException + +import auth as auth_service +import crud +from cache.shared_cache import ( + PROMPT_CACHE_TTL_SECONDS, + build_prompt_response_cache_key, + cache_get_or_set, +) +from database import run_db_call +from models import User +from routes.shared import allowed_projects, to_prompt_out +from schemas import PromptOut + + +def get_prompt_route(project: str, name: str, db, current_user: User) -> PromptOut: # type: ignore[no-untyped-def] + auth_service.ensure_project_access(current_user, project) + cache_key = build_prompt_response_cache_key(project, name) + + def _load_prompt() -> dict[str, object]: + prompt = run_db_call(db, crud.get_prompt, name, project, allowed_projects=allowed_projects(current_user)) + if not prompt: + raise HTTPException(404, "Prompt not found") + return to_prompt_out(db, prompt).model_dump(mode="json") + + cached_prompt = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_prompt) + return PromptOut(**cached_prompt) diff --git a/routes/prompts_list.py b/routes/prompts_list.py new file mode 100644 index 0000000..04fc3ab --- /dev/null +++ b/routes/prompts_list.py @@ -0,0 +1,66 @@ +from fastapi import Response +from sqlalchemy.orm import Session + +import auth as auth_service +import crud +from cache.shared_cache import ( + PROMPT_CACHE_TTL_SECONDS, + build_prompt_collection_cache_key, + cache_get_or_set, +) +from database import run_db_call +from models import User +from routes.shared import allowed_projects, to_prompt_out +from schemas import PromptOut + + +def list_prompts_route( + response: Response, + project: str | None, + tag: str | None, + limit: str | None, + offset: str | None, + db: Session, + current_user: User, +) -> list[PromptOut]: + limit_int: int | None = None + offset_int: int | None = None + try: + if limit: + limit_int = int(limit) + if limit_int < 1: + limit_int = 1 + except (ValueError, TypeError): + limit_int = None + + try: + if offset: + offset_int = int(offset) + if offset_int < 0: + offset_int = 0 + except (ValueError, TypeError): + offset_int = None + + if project is not None: + auth_service.ensure_project_access(current_user, project) + user_allowed_projects = allowed_projects(current_user) + cache_key = build_prompt_collection_cache_key( + route="prompts.list", + project=project, + tag=tag, + limit=limit_int, + offset=offset_int, + allowed_projects=user_allowed_projects, + ) + + def _load_list() -> dict[str, object]: + total_count = run_db_call(db, crud.count_prompts, project=project, tag=tag, allowed_projects=user_allowed_projects) + prompts = run_db_call(db, crud.list_prompts, project=project, tag=tag, limit=limit_int, offset=offset_int, allowed_projects=user_allowed_projects) + return { + "total_count": total_count, + "prompts": [to_prompt_out(db, prompt).model_dump(mode="json") for prompt in prompts], + } + + cached_list = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_list) + response.headers["X-Total-Count"] = str(int(cached_list.get("total_count", 0))) + return [PromptOut(**prompt) for prompt in cached_list.get("prompts", [])] diff --git a/routes/prompts_search.py b/routes/prompts_search.py new file mode 100644 index 0000000..71022f1 --- /dev/null +++ b/routes/prompts_search.py @@ -0,0 +1,39 @@ +from sqlalchemy.orm import Session + +import auth as auth_service +import crud +from cache.shared_cache import ( + PROMPT_CACHE_TTL_SECONDS, + build_prompt_collection_cache_key, + cache_get_or_set, +) +from database import run_db_call +from models import User +from routes.shared import allowed_projects, to_prompt_out +from schemas import PromptOut + + +def search_prompts_route( + tags: list[str], + mode: str, + project: str | None, + db: Session, + current_user: User, +) -> list[PromptOut]: + if project is not None: + auth_service.ensure_project_access(current_user, project) + user_allowed_projects = allowed_projects(current_user) + cache_key = build_prompt_collection_cache_key( + route="prompts.search", + project=project, + tags=tags, + mode=mode, + allowed_projects=user_allowed_projects, + ) + + def _load_search() -> list[dict[str, object]]: + prompts = run_db_call(db, crud.search_prompts_by_tags, tags=tags, mode=mode, project=project, allowed_projects=user_allowed_projects) + return [to_prompt_out(db, prompt).model_dump(mode="json") for prompt in prompts] + + cached_prompts = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_search) + return [PromptOut(**prompt) for prompt in cached_prompts] diff --git a/routes/prompts_update.py b/routes/prompts_update.py new file mode 100644 index 0000000..ad11c46 --- /dev/null +++ b/routes/prompts_update.py @@ -0,0 +1,43 @@ +from fastapi import HTTPException +from loguru import logger + +import auth as auth_service +import crud +from database import run_db_call +from models import User +from routes.shared import allowed_projects, invalidate_prompt_cache, to_prompt_version_out +from schemas import PromptUpdate, PromptVersionOut + + +def update_prompt_route(project: str, name: str, data: PromptUpdate, db, current_user: User) -> PromptVersionOut: # type: ignore[no-untyped-def] + logger.info("prompt.update project={} name={}", project, name) + auth_service.ensure_project_access(current_user, project) + prompt = run_db_call(db, crud.get_prompt, name, project, allowed_projects=allowed_projects(current_user)) + if not prompt: + logger.warning("prompt.update.not_found project={} name={}", project, name) + raise HTTPException(404, "Prompt not found") + + if data.tags is not None: + run_db_call(db, crud.set_prompt_tags, prompt, data.tags, actor_id=current_user.id) + + try: + new_version = run_db_call( + db, + crud.add_version, + prompt.id, + task=data.task, + actor_id=current_user.id, + role=data.role, + context=data.context, + constraints=data.constraints, + output_format=data.output_format, + examples=data.examples, + ) + except ValueError as exc: + logger.warning("prompt.update.conflict project={} name={} error={}", project, name, str(exc)) + raise HTTPException(409, str(exc)) from exc + invalidate_prompt_cache(project, name) + refreshed_version = run_db_call(db, crud.get_specific_version, prompt.id, new_version.version) + if not refreshed_version: + raise HTTPException(404, "Version not found after update") + return to_prompt_version_out(db, refreshed_version) diff --git a/routes/prompts_update_tags.py b/routes/prompts_update_tags.py new file mode 100644 index 0000000..c61feb9 --- /dev/null +++ b/routes/prompts_update_tags.py @@ -0,0 +1,22 @@ +from fastapi import HTTPException + +import auth as auth_service +import crud +from database import run_db_call +from models import User +from routes.shared import allowed_projects, invalidate_prompt_cache, to_prompt_out +from schemas import PromptOut, PromptTagsUpdate + + +def update_prompt_tags_route(project: str, name: str, data: PromptTagsUpdate, db, current_user: User) -> PromptOut: # type: ignore[no-untyped-def] + auth_service.ensure_project_access(current_user, project) + prompt = run_db_call(db, crud.get_prompt, name, project, allowed_projects=allowed_projects(current_user)) + if not prompt: + raise HTTPException(404, "Prompt not found") + + run_db_call(db, crud.set_prompt_tags, prompt, data.tags, actor_id=current_user.id) + invalidate_prompt_cache(project, name) + prompt = run_db_call(db, crud.get_prompt, name, project, allowed_projects=allowed_projects(current_user)) + if not prompt: + raise HTTPException(404, "Prompt not found after update") + return to_prompt_out(db, prompt) diff --git a/routes/prompts_version_get.py b/routes/prompts_version_get.py new file mode 100644 index 0000000..58b27a5 --- /dev/null +++ b/routes/prompts_version_get.py @@ -0,0 +1,32 @@ +from fastapi import HTTPException + +import auth as auth_service +import crud +from cache.shared_cache import ( + PROMPT_CACHE_TTL_SECONDS, + build_prompt_collection_cache_key, + cache_get_or_set, +) +from database import run_db_call +from models import User +from routes.shared import allowed_projects, to_prompt_version_out +from schemas import PromptVersionOut + + +def get_prompt_version_route(project: str, name: str, version: int, db, current_user: User) -> PromptVersionOut: # type: ignore[no-untyped-def] + auth_service.ensure_project_access(current_user, project) + cache_key = build_prompt_collection_cache_key(route="prompts.version", project=project, name=name, version=version) + + def _load_version() -> dict[str, object]: + prompt = run_db_call(db, crud.get_prompt, name, project, allowed_projects=allowed_projects(current_user)) + if not prompt: + raise HTTPException(404, "Prompt not found") + + prompt_version = run_db_call(db, crud.get_specific_version, prompt.id, version) + if not prompt_version: + raise HTTPException(404, "Version not found") + + return to_prompt_version_out(db, prompt_version).model_dump(mode="json") + + cached_version = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_version) + return PromptVersionOut(**cached_version) diff --git a/routes/prompts_versions_list.py b/routes/prompts_versions_list.py new file mode 100644 index 0000000..2af3203 --- /dev/null +++ b/routes/prompts_versions_list.py @@ -0,0 +1,29 @@ +from fastapi import HTTPException + +import auth as auth_service +import crud +from cache.shared_cache import ( + PROMPT_CACHE_TTL_SECONDS, + build_prompt_collection_cache_key, + cache_get_or_set, +) +from database import run_db_call +from models import User +from routes.shared import allowed_projects, to_prompt_version_out +from schemas import PromptVersionOut + + +def list_versions_route(project: str, name: str, db, current_user: User) -> list[PromptVersionOut]: # type: ignore[no-untyped-def] + auth_service.ensure_project_access(current_user, project) + cache_key = build_prompt_collection_cache_key(route="prompts.versions", project=project, name=name) + + def _load_versions() -> list[dict[str, object]]: + prompt = run_db_call(db, crud.get_prompt, name, project, allowed_projects=allowed_projects(current_user)) + if not prompt: + raise HTTPException(404, "Prompt not found") + + versions = run_db_call(db, crud.list_versions, prompt.id) + return [to_prompt_version_out(db, version).model_dump(mode="json") for version in versions] + + cached_versions = cache_get_or_set(cache_key, PROMPT_CACHE_TTL_SECONDS, _load_versions) + return [PromptVersionOut(**version) for version in cached_versions] diff --git a/routes/roles_list.py b/routes/roles_list.py new file mode 100644 index 0000000..1e70d6c --- /dev/null +++ b/routes/roles_list.py @@ -0,0 +1,7 @@ +import auth as auth_service +from database import run_db_call +from schemas import RoleOut + + +def list_roles_route(db) -> list[RoleOut]: # type: ignore[no-untyped-def] + return [RoleOut(**item) for item in run_db_call(db, auth_service.list_roles_out)] diff --git a/routes/serve_ui.py b/routes/serve_ui.py new file mode 100644 index 0000000..3fa0647 --- /dev/null +++ b/routes/serve_ui.py @@ -0,0 +1,5 @@ +from fastapi.responses import FileResponse + + +def serve_ui_route() -> FileResponse: + return FileResponse("ui/html/index.html") diff --git a/routes/shared.py b/routes/shared.py new file mode 100644 index 0000000..77f66fe --- /dev/null +++ b/routes/shared.py @@ -0,0 +1,89 @@ +import datetime +from typing import Any, cast + +from sqlalchemy.orm import Session + +import auth as auth_service +import crud +from cache.shared_cache import ( + PROMPT_CACHE_PREFIX, + build_prompt_response_cache_key, + clear_shared_cache, + delete_shared_cache_entry, +) +from database import run_db_call +from models import Prompt, User +from schemas import ProjectOut, PromptOut, PromptVersionOut, UserOut + + +def to_user_out(user: User) -> UserOut: + return UserOut(**auth_service.user_to_dict(user)) + + +def to_project_out(project: Any) -> ProjectOut: + return ProjectOut(id=project.id, name=project.name) + + +def get_personal_config(db: Session, current_user: User) -> dict[str, Any]: + config = run_db_call(db, auth_service.get_or_create_personal_config, current_user) + return auth_service.serialize_optimizer_config(config) + + +def allowed_projects(current_user: User) -> list[str] | None: + return auth_service.allowed_projects_for_user(current_user) + + +def normalize_utc_datetime(value: datetime.datetime | None) -> datetime.datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=datetime.UTC) + return value.astimezone(datetime.UTC) + + +def require_utc_datetime(value: datetime.datetime | None) -> datetime.datetime: + normalized = normalize_utc_datetime(value) + if normalized is None: + raise ValueError("Expected datetime value") + return normalized + + +def to_prompt_out(db: Session, prompt: Prompt) -> PromptOut: + latest = run_db_call(db, crud.get_latest_version, prompt.id) + if not latest: + raise ValueError(f"No version found for prompt {prompt.id}") + return PromptOut( + name=prompt.name, + project=prompt.project, + created_at=require_utc_datetime(cast("datetime.datetime | None", prompt.created_at)), + updated_at=require_utc_datetime(cast("datetime.datetime | None", prompt.updated_at)), + created_by_username=run_db_call(db, crud.resolve_audit_username, prompt.created_by_ref), + updated_by_username=run_db_call(db, crud.resolve_audit_username, prompt.updated_by_ref), + tags=[tag.name for tag in prompt.tags], + latest_version=latest.version, + role=latest.role, + task=latest.task, + context=latest.context, + constraints=latest.constraints, + output_format=latest.output_format, + examples=latest.examples, + ) + + +def invalidate_prompt_cache(project: str, name: str) -> None: + delete_shared_cache_entry(build_prompt_response_cache_key(project, name)) + clear_shared_cache(PROMPT_CACHE_PREFIX) + + +def to_prompt_version_out(db: Session, version: Any) -> PromptVersionOut: + return PromptVersionOut( + version=version.version, + created_at=require_utc_datetime(cast("datetime.datetime | None", version.created_at)), + created_by_username=run_db_call(db, crud.resolve_audit_username, version.created_by_ref), + role=version.role, + task=version.task, + context=version.context, + constraints=version.constraints, + output_format=version.output_format, + examples=version.examples, + ) diff --git a/routes/users_create.py b/routes/users_create.py new file mode 100644 index 0000000..ad121a0 --- /dev/null +++ b/routes/users_create.py @@ -0,0 +1,17 @@ +import auth as auth_service +from database import run_db_call +from routes.shared import to_user_out +from schemas import UserCreate, UserOut + + +def create_user_route(data: UserCreate, db) -> UserOut: # type: ignore[no-untyped-def] + user = run_db_call( + db, + auth_service.create_user_record, + username=data.username, + password=data.password, + role=data.role, + is_active=data.is_active, + projects=data.projects, + ) + return to_user_out(user) diff --git a/routes/users_delete.py b/routes/users_delete.py new file mode 100644 index 0000000..8be0f11 --- /dev/null +++ b/routes/users_delete.py @@ -0,0 +1,15 @@ +from fastapi import HTTPException, Response + +import crud +from database import run_db_call +from models import User + + +def delete_user_route(user_id: int, db, current_admin: User) -> Response: # type: ignore[no-untyped-def] + user = run_db_call(db, crud.get_user_by_id, user_id) + if not user: + raise HTTPException(404, "User not found") + if current_admin.id == user.id: + raise HTTPException(400, "Admin cannot delete self") + run_db_call(db, crud.delete_user, user) + return Response(status_code=204) diff --git a/routes/users_get.py b/routes/users_get.py new file mode 100644 index 0000000..5056886 --- /dev/null +++ b/routes/users_get.py @@ -0,0 +1,13 @@ +from fastapi import HTTPException + +import crud +from database import run_db_call +from routes.shared import to_user_out +from schemas import UserOut + + +def get_user_route(user_id: int, db) -> UserOut: # type: ignore[no-untyped-def] + user = run_db_call(db, crud.get_user_by_id, user_id) + if not user: + raise HTTPException(404, "User not found") + return to_user_out(user) diff --git a/routes/users_list.py b/routes/users_list.py new file mode 100644 index 0000000..12113df --- /dev/null +++ b/routes/users_list.py @@ -0,0 +1,8 @@ +import crud +from database import run_db_call +from routes.shared import to_user_out +from schemas import UserOut + + +def list_users_route(db) -> list[UserOut]: # type: ignore[no-untyped-def] + return [to_user_out(user) for user in run_db_call(db, crud.list_users)] diff --git a/routes/users_update.py b/routes/users_update.py new file mode 100644 index 0000000..bbc4d50 --- /dev/null +++ b/routes/users_update.py @@ -0,0 +1,27 @@ +from fastapi import HTTPException + +import auth as auth_service +import crud +from database import run_db_call +from models import User +from routes.shared import to_user_out +from schemas import UserOut, UserUpdate + + +def update_user_route(user_id: int, data: UserUpdate, db, current_admin: User) -> UserOut: # type: ignore[no-untyped-def] + user = run_db_call(db, crud.get_user_by_id, user_id) + if not user: + raise HTTPException(404, "User not found") + if current_admin.id == user.id and data.role is not None and data.role != "admin": + raise HTTPException(400, "Admin cannot remove own admin role") + updated = run_db_call( + db, + auth_service.update_user_record, + user, + username=data.username, + password=data.password, + role=data.role, + is_active=data.is_active, + projects=data.projects, + ) + return to_user_out(updated) diff --git a/routes/users_update_projects.py b/routes/users_update_projects.py new file mode 100644 index 0000000..2b8ebd5 --- /dev/null +++ b/routes/users_update_projects.py @@ -0,0 +1,14 @@ +from fastapi import HTTPException + +import crud +from database import run_db_call +from routes.shared import to_user_out +from schemas import ProjectAccessUpdate, UserOut + + +def update_user_projects_route(user_id: int, data: ProjectAccessUpdate, db) -> UserOut: # type: ignore[no-untyped-def] + user = run_db_call(db, crud.get_user_by_id, user_id) + if not user: + raise HTTPException(404, "User not found") + updated = run_db_call(db, crud.set_user_projects, user, data.projects) + return to_user_out(updated) diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..67efbdf --- /dev/null +++ b/ruff.toml @@ -0,0 +1,31 @@ +target-version = "py311" +line-length = 100 + +[lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "SIM", # flake8-simplify + "TC", # flake8-type-checking + "RUF", # ruff-specific rules +] +ignore = [ + "E501", # line too long — handled by formatter + "B008", # FastAPI uses Query/Depends in function signatures by design +] + +[lint.isort] +known-first-party = ["crud", "database", "models", "schemas"] + +[lint.per-file-ignores] +"tests/conftest.py" = ["E402"] + +[format] +quote-style = "double" +indent-style = "space" +docstring-code-format = true \ No newline at end of file diff --git a/schemas/schemas.py b/schemas/schemas.py index c31efbb..3040152 100644 --- a/schemas/schemas.py +++ b/schemas/schemas.py @@ -3,7 +3,6 @@ from pydantic import BaseModel, ConfigDict, StringConstraints - RequiredPromptText = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] diff --git a/security.py b/security.py index b18cf2e..19a5a1a 100644 --- a/security.py +++ b/security.py @@ -45,6 +45,12 @@ def _build_cipher(secret: str) -> Fernet: _PREVIOUS_CIPHERS = [_build_cipher(secret) for secret in _previous_machine_secrets() if secret != _ACTIVE_SECRET] _TOKEN_SECRET = hashlib.sha256(_derive_key("token", _ACTIVE_SECRET)).digest() +__all__ = [ + "ACCESS_TOKEN_TTL_SECONDS", + "REFRESH_TOKEN_TTL_SECONDS", + "time", +] + def encrypt_secret(value: str | None) -> str | None: if not value or not value.strip(): diff --git a/test_imports.py b/test_imports.py index 594dc4f..d277fd1 100644 --- a/test_imports.py +++ b/test_imports.py @@ -1,4 +1,5 @@ import sys + print("Testing imports...") try: from models import Base, User @@ -8,7 +9,7 @@ sys.exit(1) try: - from schemas import AuthResponse, UserLogin + from schemas import AuthResponse print(f"✓ schemas imported, AuthResponse={AuthResponse}") except Exception as e: print(f"✗ schemas error: {e}") diff --git a/tests/conftest.py b/tests/conftest.py index 9bf65bf..b0c80c5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import os import sys from collections.abc import Iterator from pathlib import Path @@ -20,14 +21,24 @@ from main import app, get_db from optimizer.service import set_runtime_optimizer_config +TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL", "").strip() + @pytest.fixture(scope="session") def engine(): # type: ignore[no-untyped-def] - test_engine = create_engine( - "sqlite://", - connect_args={"check_same_thread": False}, - poolclass=StaticPool, - ) + if TEST_DATABASE_URL: + test_engine = create_engine( + TEST_DATABASE_URL, + pool_pre_ping=True, + ) + else: + test_engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + Base.metadata.drop_all(bind=test_engine) Base.metadata.create_all(bind=test_engine) return test_engine @@ -54,11 +65,13 @@ def override_get_db() -> Iterator[Session]: startup_session_factory = sessionmaker(autocommit=False, autoflush=False, bind=db_session.get_bind()) original_session_local = main.SessionLocal + original_startup_session_local = main.StartupSessionLocal original_init_database = main.init_database app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[auth_service.get_db_session] = override_get_db main.SessionLocal = startup_session_factory + main.StartupSessionLocal = startup_session_factory main.init_database = lambda bind=None: Base.metadata.create_all(bind=db_session.get_bind()) with TestClient(app) as test_client: login_response = test_client.post( @@ -72,6 +85,7 @@ def override_get_db() -> Iterator[Session]: test_client.headers.update({"Authorization": f"Bearer {token}"}) yield test_client main.SessionLocal = original_session_local + main.StartupSessionLocal = original_startup_session_local main.init_database = original_init_database app.dependency_overrides.clear() diff --git a/tests/e2e/test_e2e_full_flow.py b/tests/e2e/test_e2e_full_flow.py index 1275c59..0bb1f8a 100644 --- a/tests/e2e/test_e2e_full_flow.py +++ b/tests/e2e/test_e2e_full_flow.py @@ -49,8 +49,6 @@ def fake_leo(fields, config_override=None): # type: ignore[no-untyped-def] put_cfg = client.put( "/optimize/config", json={ - "gp_profile": "quality", - "rounds": 3, "llm_provider": "ollama", "llm_model": "qwen2.5:0.5b", "llm_base_url": "http://127.0.0.1:11434", @@ -58,7 +56,8 @@ def fake_leo(fields, config_override=None): # type: ignore[no-untyped-def] }, ) assert put_cfg.status_code == 200 - assert put_cfg.json()["effective_gp_profile"] == "quality" + assert put_cfg.json()["effective_llm_provider"] == "ollama" + assert put_cfg.json()["effective_llm_model"] == "qwen2.5:0.5b" assert client.get("/optimize/config").status_code == 200 # Optimize with Leo engine diff --git a/tests/integration/test_auth_rbac_endpoints.py b/tests/integration/test_auth_rbac_endpoints.py index d39e9fb..1efdf3d 100644 --- a/tests/integration/test_auth_rbac_endpoints.py +++ b/tests/integration/test_auth_rbac_endpoints.py @@ -1,6 +1,9 @@ -import security +from typing import Any + from fastapi.testclient import TestClient +import security + def login_as(client: TestClient, username: str, password: str) -> str: response = client.post("/auth/login", json={"username": username, "password": password}) @@ -8,7 +11,7 @@ def login_as(client: TestClient, username: str, password: str) -> str: return response.json()["access_token"] -def login_bundle(client: TestClient, username: str, password: str) -> dict: +def login_bundle(client: TestClient, username: str, password: str) -> dict[str, Any]: response = client.post("/auth/login", json={"username": username, "password": password}) assert response.status_code == 200 return response.json() @@ -299,7 +302,7 @@ def test_admin_can_manage_projects(client): # type: ignore[no-untyped-def] def test_admin_can_list_roles(client): # type: ignore[no-untyped-def] response = client.get("/roles") assert response.status_code == 200 - assert response.json() == [{"id": 1, "name": "admin"}, {"id": 2, "name": "developer"}, {"id": 3, "name": "viewer"}] + assert [item["name"] for item in response.json()] == ["admin", "developer", "viewer"] def test_deleting_project_cascades_prompts_and_access(client, sample_prompt_payload): # type: ignore[no-untyped-def] @@ -336,4 +339,4 @@ def test_deleting_project_cascades_prompts_and_access(client, sample_prompt_payl prompts_response = client.get("/prompts") assert prompts_response.status_code == 200 - assert prompts_response.json() == [] \ No newline at end of file + assert prompts_response.json() == [] diff --git a/tests/integration/test_race_conditions.py b/tests/integration/test_race_conditions.py index f0c5f9a..f40e5ac 100644 --- a/tests/integration/test_race_conditions.py +++ b/tests/integration/test_race_conditions.py @@ -1,8 +1,7 @@ from __future__ import annotations -from collections.abc import Iterator from concurrent.futures import ThreadPoolExecutor -from pathlib import Path +from typing import TYPE_CHECKING from fastapi.testclient import TestClient from sqlalchemy import create_engine @@ -13,15 +12,24 @@ from database import Base from main import app, get_db from models import Prompt, Tag +from tests.conftest import TEST_DATABASE_URL + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path def test_http_concurrent_create_prompt_with_shared_new_tag_is_race_safe(tmp_path: Path): # type: ignore[no-untyped-def] - db_path = tmp_path / "race_http.db" - engine = create_engine( - f"sqlite:///{db_path}", - connect_args={"check_same_thread": False}, - ) + if TEST_DATABASE_URL: + engine = create_engine(TEST_DATABASE_URL, pool_pre_ping=True) + else: + db_path = tmp_path / "race_http.db" + engine = create_engine( + f"sqlite:///{db_path}", + connect_args={"check_same_thread": False}, + ) testing_session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.drop_all(bind=engine) Base.metadata.create_all(bind=engine) def override_get_db() -> Iterator[Session]: @@ -32,10 +40,12 @@ def override_get_db() -> Iterator[Session]: db.close() original_session_local = main.SessionLocal + original_startup_session_local = main.StartupSessionLocal original_init_database = main.init_database try: main.SessionLocal = testing_session_local + main.StartupSessionLocal = testing_session_local main.init_database = lambda bind=None: Base.metadata.create_all(bind=engine) app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[auth_service.get_db_session] = override_get_db @@ -90,6 +100,7 @@ def _worker(index: int) -> tuple[int, int]: verify_session.close() finally: main.SessionLocal = original_session_local + main.StartupSessionLocal = original_startup_session_local main.init_database = original_init_database app.dependency_overrides.clear() engine.dispose() diff --git a/tests/unit/test_unit_optimizer_and_crud.py b/tests/unit/test_unit_optimizer_and_crud.py index cd4924a..ce25ac1 100644 --- a/tests/unit/test_unit_optimizer_and_crud.py +++ b/tests/unit/test_unit_optimizer_and_crud.py @@ -2,13 +2,12 @@ from time import perf_counter, sleep import pytest -from sqlalchemy import insert -from sqlalchemy import create_engine +from sqlalchemy import create_engine, insert from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import sessionmaker -import crud import auth as auth_service +import crud import main import optimizer.service as optimizer_service from database import Base, init_database @@ -17,10 +16,11 @@ LeoPromptOptimizerBackend, _normalize_text, _parse_structured_response, - optimize_prompt_with_active_backend, get_runtime_optimizer_config, + optimize_prompt_with_active_backend, set_runtime_optimizer_config, ) +from tests.conftest import TEST_DATABASE_URL def test_normalize_tags_deduplicates_and_sorts(): # type: ignore[no-untyped-def] @@ -283,12 +283,16 @@ def test_init_database_and_bootstrap_seed_default_roles(db_session): # type: ig def test_concurrent_create_prompt_with_shared_new_tag_is_race_safe(tmp_path): # type: ignore[no-untyped-def] - db_path = tmp_path / "race_test.db" - engine = create_engine( - f"sqlite:///{db_path}", - connect_args={"check_same_thread": False}, - ) + if TEST_DATABASE_URL: + engine = create_engine(TEST_DATABASE_URL, pool_pre_ping=True) + else: + db_path = tmp_path / "race_test.db" + engine = create_engine( + f"sqlite:///{db_path}", + connect_args={"check_same_thread": False}, + ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.drop_all(bind=engine) Base.metadata.create_all(bind=engine) try: From c7389a560712cc958636f5c3b1d8a61648bf9770 Mon Sep 17 00:00:00 2001 From: Somebody Else Date: Tue, 19 May 2026 10:16:09 +0300 Subject: [PATCH 2/3] Exclude some dirs from test --- .github/workflows/ci.yml | 2 +- pyproject.toml | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dba581..c29cd3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: python -m pip install mypy sqlalchemy[mypy] types-requests httpx pytest - name: Run mypy - run: python -m mypy . + run: python -m mypy . --exclude loadtests --exclude tests tests: runs-on: ubuntu-latest diff --git a/pyproject.toml b/pyproject.toml index c188736..3db4c84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ disallow_incomplete_defs = true check_untyped_defs = true no_implicit_optional = true explicit_package_bases = true -exclude = ["^snippets/"] +exclude = ["^snippets/", "^loadtests/", "^tests/"] [[tool.mypy.overrides]] module = ["alembic.*"] @@ -68,12 +68,6 @@ ignore_missing_imports = true module = ["leo_prompt_optimizer.*"] ignore_missing_imports = true -[[tool.mypy.overrides]] -module = ["tests.*"] -disallow_untyped_defs = false -warn_return_any = false -warn_unused_ignores = false - [[tool.mypy.overrides]] module = ["app_core.*", "cache.*", "database", "middleware.*", "models", "models.*", "optimizer.*", "routes.shared"] strict = true From ddfd79e9cf848bea930d6bcebe979a4358dee53b Mon Sep 17 00:00:00 2001 From: Somebody Else Date: Tue, 19 May 2026 10:24:45 +0300 Subject: [PATCH 3/3] remove ci.yml. Bye-bye codecov ) --- .github/workflows/ci.yml | 81 ---------------------------------------- 1 file changed, 81 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index c29cd3b..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: CI - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - workflow_dispatch: - -permissions: - contents: read - -jobs: - ruff: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install lint dependencies - run: | - python -m pip install --upgrade pip - python -m pip install ruff - - - name: Run Ruff - run: python -m ruff check . - - mypy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install mypy dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install mypy sqlalchemy[mypy] types-requests httpx pytest - - - name: Run mypy - run: python -m mypy . --exclude loadtests --exclude tests - - tests: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install test dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -r requirements.txt - python -m pip install pytest pytest-cov httpx sqlalchemy[mypy] types-requests - - - name: Run tests with coverage - run: python -m pytest tests --cov --cov-report=xml --cov-report=term-missing -q - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v5 - with: - files: ./coverage.xml - fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }}