velocity-python 0.1.72__tar.gz → 0.1.74__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {velocity_python-0.1.72 → velocity_python-0.1.74}/PKG-INFO +1 -1
- {velocity_python-0.1.72 → velocity_python-0.1.74}/pyproject.toml +1 -1
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/__init__.py +1 -1
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/async_support.py +50 -24
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/engine.py +71 -6
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/result.py +27 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/transaction.py +194 -19
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity_python.egg-info/PKG-INFO +1 -1
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity_python.egg-info/SOURCES.txt +2 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_async_support.py +41 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_connection_resilience.py +2 -2
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_retry_side_effect_guard.py +3 -3
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_single_autocommit_safety.py +1 -0
- velocity_python-0.1.74/tests/test_transaction_commit_and_ownership.py +240 -0
- velocity_python-0.1.74/tests/test_transaction_edge_cases.py +225 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/LICENSE +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/README.md +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/setup.cfg +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/amplify.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/amplify_build.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/assets/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/assets/backfill.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/assets/indexing.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/assets/references.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/assets/service.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/assets/usage_index.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/dirty_pipeline.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/base_handler.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/context.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/context_factory.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/exceptions.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/lambda_handler.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/masquerade.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/mixins/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/mixins/data_service.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/mixins/web_handler.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/perf.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/response.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/handlers/sqs_handler.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/s3.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/ssm_config.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/tests/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/tests/test_base_handler_error_response.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/tests/test_lambda_handler_json_serialization.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/aws/tests/test_response.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/column.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/database.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/decorators.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/jsonproxy.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/row.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/sequence.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/table.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/core/view.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/exceptions.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/migrations.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/base/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/base/initializer.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/base/operators.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/base/sql.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/base/types.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/mysql/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/mysql/operators.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/mysql/reserved.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/mysql/sql.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/mysql/types.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/postgres/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/postgres/operators.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/postgres/reserved.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/postgres/sql.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/postgres/types.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlite/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlite/operators.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlite/reserved.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlite/sql.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlite/types.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlserver/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlserver/operators.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlserver/reserved.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlserver/sql.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/sqlserver/types.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/servers/tablehelper.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/common_db_test.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/common.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/conftest.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_column.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_connections.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_database.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_engine.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_general_usage.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_imports.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_result.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_row.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_row_comprehensive.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_schema_locking.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_schema_locking_unit.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_sequence.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_sql_comprehensive.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_table.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_table_comprehensive.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/postgres/test_transaction.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/sql/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/sql/common.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/sql/test_postgres_select_advanced.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/sql/test_postgres_select_variances.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_cursor_rowcount_fix.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_db_utils.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_postgres.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_postgres_unchanged.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_process_error_robustness.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_result_caching.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_result_sql_aware.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_row_get_missing_column.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_schema_locking_initializers.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_schema_locking_simple.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_sql_builder.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_tablehelper.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/tests/test_view_helper.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/db/utils.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/logging.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/conv/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/conv/iconv.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/conv/oconv.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/db.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/export.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/format.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/mail.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/merge.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/pdf.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tests/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tests/test_db.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tests/test_fix.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tests/test_format.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tests/test_iconv.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tests/test_merge.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tests/test_oconv.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tests/test_original_error.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tests/test_timer.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/timer.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/misc/tools.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/payment/__init__.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/payment/authorizenet_adapter.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/payment/authorizenet_mirror.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/payment/base_adapter.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/payment/braintree_adapter.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/payment/braintree_mirror.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/payment/charge_rules.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/payment/stripe_adapter.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity/payment/stripe_mirror.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity_python.egg-info/dependency_links.txt +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity_python.egg-info/entry_points.txt +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity_python.egg-info/requires.txt +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/src/velocity_python.egg-info/top_level.txt +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_amplify_build.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_asset_indexing.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_asset_references.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_assets_service.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_batch_operations.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_column_tx_arg.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_concurrency_safety.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_connection_pool.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_context_job_descriptions.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_db_credentials_ssm_cascade.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_decorators.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_dirty_pipeline_fast_path.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_email_processing.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_get_cognito_user_provider.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_http_handler_rollback.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_iconv_money_to_cents.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_identifier_injection_guard.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_json_columns.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_jsonb_dict_adapter.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_lambda_handler.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_lambda_handler_auth.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_lambda_handler_masquerade.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_masquerade_grant.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_mixins_import.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_n_plus_one.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_observability.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_payment_authorizenet_adapter.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_payment_braintree_adapter.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_payment_braintree_mirror.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_payment_profile_sorting.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_payment_stripe_adapter.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_pdf.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_prepared_statements.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_psycopg3_upgrade.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_query_cache.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_restricted_direct_tables.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_return_default_safety.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_row_batch_update.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_row_cache_staleness.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_schema_migrations.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_security_hardening.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_server_cursor.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_spreadsheet_functions.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_sqlite_backend.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_sqs_per_record_transactions.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_ssm_config.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_store_user_data.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_sys_modified_count_postgres_demo.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_table_alter.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_transaction_class_wrapping.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_where_clause_validation.py +0 -0
- {velocity_python-0.1.72 → velocity_python-0.1.74}/tests/test_write_hook_create_flow.py +0 -0
|
@@ -28,6 +28,7 @@ from __future__ import annotations
|
|
|
28
28
|
import asyncio
|
|
29
29
|
import logging
|
|
30
30
|
import os
|
|
31
|
+
import re
|
|
31
32
|
import time as _time
|
|
32
33
|
from collections import OrderedDict
|
|
33
34
|
from collections.abc import Mapping
|
|
@@ -426,6 +427,16 @@ class AsyncTransaction:
|
|
|
426
427
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
427
428
|
if exc_type:
|
|
428
429
|
await self.rollback()
|
|
430
|
+
# Discard the connection on the error path WITHOUT committing.
|
|
431
|
+
# (The previous code fell through to close(), which committed even
|
|
432
|
+
# after a rollback.)
|
|
433
|
+
if self.connection:
|
|
434
|
+
try:
|
|
435
|
+
await self.connection.close()
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
self.connection = None
|
|
439
|
+
return # do not suppress the original exception
|
|
429
440
|
await self.close()
|
|
430
441
|
|
|
431
442
|
async def _ensure_connection(self):
|
|
@@ -507,21 +518,23 @@ class AsyncTransaction:
|
|
|
507
518
|
stack_info=True,
|
|
508
519
|
)
|
|
509
520
|
|
|
510
|
-
# R14 — N+1 detection
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
521
|
+
# R14 — N+1 detection / R33 — raw-write cache invalidation
|
|
522
|
+
from velocity.db.core.transaction import _classify_sql, _extract_table_name
|
|
523
|
+
op = _classify_sql(sql)
|
|
524
|
+
if _N_PLUS_1_THRESHOLD and op == "SELECT":
|
|
525
|
+
tbl = _extract_table_name(sql)
|
|
526
|
+
if tbl:
|
|
527
|
+
self._table_select_counts[tbl] = self._table_select_counts.get(tbl, 0) + 1
|
|
528
|
+
count = self._table_select_counts[tbl]
|
|
529
|
+
if count > _N_PLUS_1_THRESHOLD and tbl not in self._n1_warned:
|
|
530
|
+
self._n1_warned.add(tbl)
|
|
531
|
+
_logger.warning(
|
|
532
|
+
"Possible N+1: table %s queried %d times in async transaction",
|
|
533
|
+
tbl, count,
|
|
534
|
+
)
|
|
535
|
+
# Keep the query cache correct for raw writes that bypass the table API.
|
|
536
|
+
if self._query_cache and op in ("INSERT", "UPDATE", "DELETE", "DDL"):
|
|
537
|
+
self.invalidate_cache(_extract_table_name(sql))
|
|
525
538
|
|
|
526
539
|
result = AsyncResult(cursor, self, sql, parms)
|
|
527
540
|
await result._init()
|
|
@@ -554,7 +567,13 @@ class AsyncTransaction:
|
|
|
554
567
|
async def commit(self):
|
|
555
568
|
"""Commit the async transaction."""
|
|
556
569
|
if self.connection:
|
|
557
|
-
|
|
570
|
+
try:
|
|
571
|
+
await self.connection.commit()
|
|
572
|
+
except Exception as e:
|
|
573
|
+
# Classify commit-time failures (serialization failure, deadlock,
|
|
574
|
+
# deferred-constraint violation) like the sync path does, instead
|
|
575
|
+
# of letting a raw driver error escape.
|
|
576
|
+
raise self.engine.process_error(e)
|
|
558
577
|
if self._query_count:
|
|
559
578
|
_logger.debug(
|
|
560
579
|
"Async transaction commit: %d queries in %.1f ms",
|
|
@@ -570,12 +589,17 @@ class AsyncTransaction:
|
|
|
570
589
|
pass
|
|
571
590
|
|
|
572
591
|
async def close(self):
|
|
573
|
-
"""Commit (if needed) and close the async connection.
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
592
|
+
"""Commit (if needed) and close the async connection.
|
|
593
|
+
|
|
594
|
+
A commit failure is **re-raised** (after the connection is closed)
|
|
595
|
+
rather than swallowed — otherwise a failed COMMIT would look successful
|
|
596
|
+
and the caller's writes would be silently lost.
|
|
597
|
+
"""
|
|
598
|
+
if not self.connection:
|
|
599
|
+
return
|
|
600
|
+
try:
|
|
601
|
+
await self.commit()
|
|
602
|
+
finally:
|
|
579
603
|
try:
|
|
580
604
|
await self.connection.close()
|
|
581
605
|
except Exception:
|
|
@@ -647,7 +671,9 @@ class AsyncTransaction:
|
|
|
647
671
|
if table_name is None:
|
|
648
672
|
self._query_cache.clear()
|
|
649
673
|
return
|
|
650
|
-
|
|
651
|
-
|
|
674
|
+
# R33 — whole-word match (see sync Transaction.invalidate_cache): a
|
|
675
|
+
# substring match would wrongly evict "users" when invalidating "user".
|
|
676
|
+
pattern = re.compile(r"\b" + re.escape(table_name.lower()) + r"\b")
|
|
677
|
+
to_remove = [k for k in self._query_cache if pattern.search(k[0].lower())]
|
|
652
678
|
for k in to_remove:
|
|
653
679
|
del self._query_cache[k]
|
|
@@ -61,6 +61,27 @@ def _build_error_code_map(sql_dialect) -> dict:
|
|
|
61
61
|
_ENGINE_FILE = os.path.normpath(__file__)
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
# R34 — Optional wall-clock ceiling on the retry envelope. The count caps
|
|
65
|
+
# (MAX_RETRIES=100 for deadlock/serialization) combined with up to 2s of backoff
|
|
66
|
+
# per attempt can spin for minutes on a *deterministic* conflict, burning a whole
|
|
67
|
+
# Lambda/request timeout. A budget lets a caller bound total retry wall time.
|
|
68
|
+
# 0 (default) = disabled, preserving the historical count-only behaviour.
|
|
69
|
+
_RETRY_BUDGET_SECONDS = float(os.environ.get("VELOCITY_RETRY_BUDGET_SECONDS", "0"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _retry_budget_exhausted(loop_start):
|
|
73
|
+
"""R34 — True (and log) once the cumulative retry wall time exceeds the
|
|
74
|
+
configured budget, so the envelope re-raises instead of retrying forever."""
|
|
75
|
+
if _RETRY_BUDGET_SECONDS and (time.perf_counter() - loop_start) > _RETRY_BUDGET_SECONDS:
|
|
76
|
+
logger.error(
|
|
77
|
+
"Retry wall-clock budget of %.1fs exhausted; re-raising instead of "
|
|
78
|
+
"continuing to retry (VELOCITY_RETRY_BUDGET_SECONDS).",
|
|
79
|
+
_RETRY_BUDGET_SECONDS,
|
|
80
|
+
)
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
64
85
|
def _retry_blocked_by_side_effect(tx):
|
|
65
86
|
"""R21 — return True (and log) when *tx* has performed an irreversible
|
|
66
87
|
external side effect, so the auto-retry envelope must re-raise instead of
|
|
@@ -530,12 +551,18 @@ class Engine:
|
|
|
530
551
|
tx = args[pos]
|
|
531
552
|
|
|
532
553
|
if tx:
|
|
554
|
+
# Joined a caller-owned transaction: the caller drives
|
|
555
|
+
# commit/rollback/retry. _owns_tx stays False.
|
|
533
556
|
return self.exec_function(func_or_cls, tx, *args, **kwds)
|
|
534
557
|
|
|
535
558
|
with Transaction(self) as local_tx:
|
|
536
559
|
pos = names.index("tx")
|
|
537
560
|
new_args = args[:pos] + (local_tx,) + args[pos:]
|
|
538
|
-
|
|
561
|
+
# We created this transaction, so we own it: commit it and
|
|
562
|
+
# act as the retry boundary.
|
|
563
|
+
return self.exec_function(
|
|
564
|
+
func_or_cls, local_tx, *new_args, _owns_tx=True, **kwds
|
|
565
|
+
)
|
|
539
566
|
|
|
540
567
|
return new_function
|
|
541
568
|
|
|
@@ -579,28 +606,62 @@ class Engine:
|
|
|
579
606
|
|
|
580
607
|
return Transaction(self)
|
|
581
608
|
|
|
582
|
-
def exec_function(self, function, _tx, *args, **kwds):
|
|
609
|
+
def exec_function(self, function, _tx, *args, _owns_tx=False, **kwds):
|
|
583
610
|
"""
|
|
584
611
|
Executes the given function inside the transaction `_tx`.
|
|
585
|
-
|
|
612
|
+
|
|
613
|
+
Retry / commit semantics:
|
|
614
|
+
|
|
615
|
+
* When this invocation **owns** the transaction (``_owns_tx=True`` — the
|
|
616
|
+
decorator created it for an omitted-``tx`` call) and is the outermost
|
|
617
|
+
such invocation (``depth == 0``), it is the *retry boundary*. It
|
|
618
|
+
commits the transaction **inside** the retry envelope, so commit-time
|
|
619
|
+
serialization failures / deadlocks / deferred-constraint violations
|
|
620
|
+
are classified and retried too — not just statement-time errors. It
|
|
621
|
+
retries on ``DbRetryTransaction`` / ``DbLockTimeoutError`` (up to
|
|
622
|
+
``MAX_RETRIES``) and on transient ``DbConnectionError`` (up to 6).
|
|
623
|
+
|
|
624
|
+
* When the transaction was **passed in** (``_owns_tx=False``), the caller
|
|
625
|
+
owns it. This invocation must NOT commit, roll back, or retry — a
|
|
626
|
+
rollback would silently discard the caller's other uncommitted work on
|
|
627
|
+
the same transaction, and a retry would re-run only this function
|
|
628
|
+
against state the caller no longer has. It simply runs the function;
|
|
629
|
+
the owner drives commit/rollback/retry. Nested calls (``depth > 0``)
|
|
630
|
+
are likewise pass-through.
|
|
631
|
+
|
|
632
|
+
Note: a manually created ``with engine.transaction() as tx:`` that is
|
|
633
|
+
passed to a decorated function therefore gets no auto-retry — that is
|
|
634
|
+
intentional. Wrap the outer scope in ``@engine.transaction`` (so the
|
|
635
|
+
framework owns the transaction) if you want commit-and-retry semantics.
|
|
586
636
|
"""
|
|
587
637
|
depth = getattr(_tx, "_exec_function_depth", 0)
|
|
588
638
|
setattr(_tx, "_exec_function_depth", depth + 1)
|
|
589
639
|
|
|
590
640
|
try:
|
|
591
|
-
if depth
|
|
592
|
-
#
|
|
641
|
+
if not (_owns_tx and depth == 0):
|
|
642
|
+
# Joined a caller-owned transaction, or a nested call. Just run
|
|
643
|
+
# the function; the transaction's owner handles commit, rollback
|
|
644
|
+
# and retry. Retrying here would roll back work we don't own.
|
|
593
645
|
return function(*args, **kwds)
|
|
594
646
|
else:
|
|
595
647
|
retry_count = 0
|
|
596
648
|
lock_timeout_count = 0
|
|
597
649
|
connection_retry_count = 0
|
|
650
|
+
loop_start = time.perf_counter()
|
|
598
651
|
while True:
|
|
599
652
|
try:
|
|
600
|
-
|
|
653
|
+
result = function(*args, **kwds)
|
|
654
|
+
# Commit INSIDE the retry envelope so a commit-time
|
|
655
|
+
# serialization failure / deadlock / deferred-constraint
|
|
656
|
+
# violation is classified and retried, instead of
|
|
657
|
+
# escaping as a raw driver error after the loop exits.
|
|
658
|
+
_tx.commit()
|
|
659
|
+
return result
|
|
601
660
|
except exceptions.DbRetryTransaction:
|
|
602
661
|
if _retry_blocked_by_side_effect(_tx):
|
|
603
662
|
raise
|
|
663
|
+
if _retry_budget_exhausted(loop_start):
|
|
664
|
+
raise
|
|
604
665
|
retry_count += 1
|
|
605
666
|
if retry_count > self.MAX_RETRIES:
|
|
606
667
|
raise
|
|
@@ -610,6 +671,8 @@ class Engine:
|
|
|
610
671
|
except exceptions.DbLockTimeoutError:
|
|
611
672
|
if _retry_blocked_by_side_effect(_tx):
|
|
612
673
|
raise
|
|
674
|
+
if _retry_budget_exhausted(loop_start):
|
|
675
|
+
raise
|
|
613
676
|
lock_timeout_count += 1
|
|
614
677
|
if lock_timeout_count > self.MAX_RETRIES:
|
|
615
678
|
raise
|
|
@@ -624,6 +687,8 @@ class Engine:
|
|
|
624
687
|
# double-charge a customer.
|
|
625
688
|
if _retry_blocked_by_side_effect(_tx):
|
|
626
689
|
raise
|
|
690
|
+
if _retry_budget_exhausted(loop_start):
|
|
691
|
+
raise
|
|
627
692
|
msg = str(e).strip().lower()
|
|
628
693
|
if not getattr(
|
|
629
694
|
self.sql, "is_transient_connection_error_message", lambda _m: False
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from velocity.misc.format import to_json
|
|
2
2
|
from velocity.db.core.row import Row
|
|
3
|
+
from velocity.db import exceptions
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
class Result:
|
|
@@ -52,6 +53,26 @@ class Result:
|
|
|
52
53
|
# Pre-fetch the first row to enable immediate boolean evaluation
|
|
53
54
|
self._fetch_first_row()
|
|
54
55
|
|
|
56
|
+
def _outlived_transaction(self):
|
|
57
|
+
"""True when a fetch failed because this Result outlived its transaction.
|
|
58
|
+
|
|
59
|
+
Once the owning transaction commits/closes it releases its connection
|
|
60
|
+
(``tx.connection`` becomes ``None``) — and, when pooled, that connection
|
|
61
|
+
may already be serving another transaction. Continuing to read this
|
|
62
|
+
Result's cursor then returns nothing (or another transaction's data).
|
|
63
|
+
Silently reporting "no more rows" in that case hides real data, so the
|
|
64
|
+
caller is told to materialize inside the transaction instead.
|
|
65
|
+
"""
|
|
66
|
+
tx = self.__tx
|
|
67
|
+
return tx is not None and getattr(tx, "connection", None) is None
|
|
68
|
+
|
|
69
|
+
_OUTLIVED_TX_MESSAGE = (
|
|
70
|
+
"Result was iterated after its transaction was committed/closed — the "
|
|
71
|
+
"rows are no longer available (and a pooled connection may now belong to "
|
|
72
|
+
"another transaction). Materialize the result inside the transaction "
|
|
73
|
+
"(.all()/.one()/.scalar()/list(...)) before returning it."
|
|
74
|
+
)
|
|
75
|
+
|
|
55
76
|
def _fetch_first_row(self):
|
|
56
77
|
"""
|
|
57
78
|
Pre-fetch the first row from the cursor to enable immediate boolean evaluation.
|
|
@@ -138,6 +159,8 @@ class Result:
|
|
|
138
159
|
self._cursor = None
|
|
139
160
|
if isinstance(e, StopIteration):
|
|
140
161
|
raise
|
|
162
|
+
if self._outlived_transaction():
|
|
163
|
+
raise exceptions.DbException(self._OUTLIVED_TX_MESSAGE)
|
|
141
164
|
raise StopIteration
|
|
142
165
|
else:
|
|
143
166
|
raise StopIteration
|
|
@@ -168,6 +191,8 @@ class Result:
|
|
|
168
191
|
# If cursor is closed or has error, mark as exhausted
|
|
169
192
|
self._exhausted = True
|
|
170
193
|
self._cursor = None
|
|
194
|
+
if self._outlived_transaction():
|
|
195
|
+
raise exceptions.DbException(self._OUTLIVED_TX_MESSAGE)
|
|
171
196
|
|
|
172
197
|
def batch(self, qty=1):
|
|
173
198
|
"""
|
|
@@ -355,6 +380,8 @@ class Result:
|
|
|
355
380
|
# If cursor error, return default
|
|
356
381
|
self._exhausted = True
|
|
357
382
|
self._cursor = None
|
|
383
|
+
if self._outlived_transaction():
|
|
384
|
+
raise exceptions.DbException(self._OUTLIVED_TX_MESSAGE)
|
|
358
385
|
return default
|
|
359
386
|
return default
|
|
360
387
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import os
|
|
3
|
+
import re
|
|
3
4
|
import time as _time
|
|
4
5
|
import traceback
|
|
5
6
|
import uuid
|
|
6
7
|
from collections import OrderedDict
|
|
8
|
+
from contextlib import contextmanager
|
|
7
9
|
|
|
10
|
+
from velocity.db import exceptions
|
|
8
11
|
from velocity.db.core.row import Row
|
|
9
12
|
from velocity.db.core.table import Table
|
|
10
13
|
from velocity.db.core.view import View
|
|
@@ -185,6 +188,12 @@ class Transaction:
|
|
|
185
188
|
# auto-retry envelope must NOT re-run a function after this, or a
|
|
186
189
|
# transient DB error post-charge would charge the customer again.
|
|
187
190
|
self._external_side_effect = False
|
|
191
|
+
# R31 — True once a statement has executed on the current connection and
|
|
192
|
+
# has not yet been committed/rolled back. Used to detect a connection
|
|
193
|
+
# that died *mid-transaction*: silently reconnecting there would start a
|
|
194
|
+
# fresh transaction and lose the prior uncommitted statements without
|
|
195
|
+
# any error. Reset on connect/commit/rollback.
|
|
196
|
+
self._in_progress = False
|
|
188
197
|
|
|
189
198
|
def __str__(self):
|
|
190
199
|
config = mask_config_for_display(self.engine.config)
|
|
@@ -204,15 +213,54 @@ class Transaction:
|
|
|
204
213
|
if debug:
|
|
205
214
|
print("Transaction.__exit__ - an exception occurred.")
|
|
206
215
|
traceback.print_exc()
|
|
207
|
-
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
self.
|
|
216
|
+
# Roll back, but never let a secondary failure here (e.g. rolling
|
|
217
|
+
# back an already-dead connection) mask the original exception that
|
|
218
|
+
# triggered the unwind.
|
|
219
|
+
try:
|
|
220
|
+
self.rollback()
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
# After an error, discard the connection rather than returning or
|
|
224
|
+
# reusing it in a potentially dirty state. Release the pool slot
|
|
225
|
+
# in either mode so an error can't leak it.
|
|
226
|
+
if self.connection:
|
|
227
|
+
if self.__pooled:
|
|
228
|
+
self.engine.return_pooled_connection(self.connection, discard=True)
|
|
229
|
+
else:
|
|
230
|
+
try:
|
|
231
|
+
self.connection.close()
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
212
234
|
self.connection = None
|
|
213
235
|
self.__pooled = False
|
|
236
|
+
return # do not suppress the exception
|
|
214
237
|
self.close()
|
|
215
238
|
|
|
239
|
+
def _discard_dead_connection(self):
|
|
240
|
+
"""Drop the current connection because it's dead/unusable.
|
|
241
|
+
|
|
242
|
+
If it was a pooled connection, return it to the pool with ``discard=True``
|
|
243
|
+
so the pool **releases its semaphore slot** (and decrements its count) —
|
|
244
|
+
otherwise a connection that died while checked out leaks a slot forever,
|
|
245
|
+
and enough such leaks exhaust the pool. Non-pooled connections are just
|
|
246
|
+
closed. Always clears ``_in_progress`` since any open work is gone.
|
|
247
|
+
"""
|
|
248
|
+
dead = self.connection
|
|
249
|
+
self.connection = None
|
|
250
|
+
self._in_progress = False
|
|
251
|
+
if dead is None:
|
|
252
|
+
return
|
|
253
|
+
if self.__pooled:
|
|
254
|
+
try:
|
|
255
|
+
self.engine.return_pooled_connection(dead, discard=True)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
else:
|
|
259
|
+
try:
|
|
260
|
+
dead.close()
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
|
|
216
264
|
def cursor(self):
|
|
217
265
|
"""
|
|
218
266
|
Retrieves a database cursor, opening a connection if necessary.
|
|
@@ -221,14 +269,32 @@ class Transaction:
|
|
|
221
269
|
# (psycopg v3 uses `connection.closed` returning bool; v2 used int != 0.)
|
|
222
270
|
if self.connection is not None:
|
|
223
271
|
try:
|
|
224
|
-
|
|
225
|
-
self.connection = None
|
|
272
|
+
is_closed = bool(getattr(self.connection, "closed", 0))
|
|
226
273
|
except Exception:
|
|
227
274
|
# If the driver object is in a bad state, force a reconnect.
|
|
228
|
-
|
|
275
|
+
is_closed = True
|
|
276
|
+
if is_closed:
|
|
277
|
+
# R31 — If statements already ran in this transaction, silently
|
|
278
|
+
# reconnecting would open a NEW connection (a fresh transaction)
|
|
279
|
+
# and discard that uncommitted work with no error. Surface it as
|
|
280
|
+
# a transient connection error instead, so the @engine.transaction
|
|
281
|
+
# retry envelope re-runs the whole unit of work from the start
|
|
282
|
+
# rather than committing a partial/empty transaction.
|
|
283
|
+
in_progress = self._in_progress
|
|
284
|
+
# Discard the dead connection properly (releases the pool slot;
|
|
285
|
+
# the previous code nulled it and leaked the slot).
|
|
286
|
+
self._discard_dead_connection()
|
|
287
|
+
if in_progress:
|
|
288
|
+
raise exceptions.DbConnectionError(
|
|
289
|
+
"Database connection already closed mid-transaction; "
|
|
290
|
+
"uncommitted work in this transaction was lost and cannot "
|
|
291
|
+
"be recovered in place. (A function wrapped by "
|
|
292
|
+
"@engine.transaction will be retried from the start.)"
|
|
293
|
+
)
|
|
229
294
|
|
|
230
295
|
if not self.connection:
|
|
231
296
|
self.connection = self.engine.connect()
|
|
297
|
+
self._in_progress = False
|
|
232
298
|
if debug:
|
|
233
299
|
print(f"*** {id(self)} --> transaction.cursor()")
|
|
234
300
|
return self.connection.cursor()
|
|
@@ -239,15 +305,36 @@ class Transaction:
|
|
|
239
305
|
If the connection was obtained from the pool, it is returned to
|
|
240
306
|
the pool instead of being closed.
|
|
241
307
|
"""
|
|
242
|
-
if self.connection:
|
|
308
|
+
if not self.connection:
|
|
309
|
+
return
|
|
310
|
+
if debug:
|
|
311
|
+
print(f"<<< {id(self)} close connection.")
|
|
312
|
+
try:
|
|
243
313
|
self.commit()
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
314
|
+
except Exception:
|
|
315
|
+
# A commit-time failure (serialization failure, deadlock, or a
|
|
316
|
+
# deferred-constraint violation — all of which surface at COMMIT,
|
|
317
|
+
# not at statement time) leaves the connection in an aborted state.
|
|
318
|
+
# Discard it rather than returning a dirty connection to the pool,
|
|
319
|
+
# and ALWAYS release the pool slot so a commit failure can't leak
|
|
320
|
+
# it. Repeated commit-time failures would otherwise exhaust the
|
|
321
|
+
# pool, turning a transient error into a hard outage. Then re-raise
|
|
322
|
+
# so the caller still sees the commit error.
|
|
323
|
+
if self.__pooled:
|
|
324
|
+
self.engine.return_pooled_connection(self.connection, discard=True)
|
|
248
325
|
else:
|
|
249
|
-
|
|
250
|
-
|
|
326
|
+
try:
|
|
327
|
+
self.connection.close()
|
|
328
|
+
except Exception:
|
|
329
|
+
pass
|
|
330
|
+
self.connection = None
|
|
331
|
+
self.__pooled = False
|
|
332
|
+
raise
|
|
333
|
+
if self.__pooled and self.engine.return_pooled_connection(self.connection):
|
|
334
|
+
self.connection = None
|
|
335
|
+
else:
|
|
336
|
+
self.connection.close()
|
|
337
|
+
self.connection = None
|
|
251
338
|
|
|
252
339
|
def execute(self, sql, parms=None, single=False, cursor=None, prepare=None):
|
|
253
340
|
return self._execute(sql, parms, single, cursor, prepare=prepare)
|
|
@@ -259,6 +346,18 @@ class Transaction:
|
|
|
259
346
|
self.connection = self.engine.connect()
|
|
260
347
|
|
|
261
348
|
if single:
|
|
349
|
+
# R32 — single=True needs autocommit mode, which can't have an open
|
|
350
|
+
# transaction, so it must commit first. If there is uncommitted work
|
|
351
|
+
# pending, that work is silently committed here — warn, because
|
|
352
|
+
# single=True is meant for standalone statements (e.g. CREATE
|
|
353
|
+
# DATABASE) on a clean transaction, not mid-unit-of-work.
|
|
354
|
+
if self._in_progress:
|
|
355
|
+
_logger.warning(
|
|
356
|
+
"execute(single=True) is committing work already pending in "
|
|
357
|
+
"this transaction before switching to autocommit. single=True "
|
|
358
|
+
"is for standalone statements on a clean transaction; running "
|
|
359
|
+
"it mid-transaction silently commits the prior statements."
|
|
360
|
+
)
|
|
262
361
|
self.commit()
|
|
263
362
|
self.connection.autocommit = True
|
|
264
363
|
|
|
@@ -309,6 +408,20 @@ class Transaction:
|
|
|
309
408
|
elapsed_ms = (_time.perf_counter() - t0) * 1000
|
|
310
409
|
self._query_count += 1
|
|
311
410
|
self._query_time_ms += elapsed_ms
|
|
411
|
+
if not single:
|
|
412
|
+
# A statement is now pending in an open transaction (single=True
|
|
413
|
+
# runs in autocommit, so nothing is left pending there).
|
|
414
|
+
self._in_progress = True
|
|
415
|
+
|
|
416
|
+
# R33 — Keep the query cache correct for raw writes that bypass the
|
|
417
|
+
# Table API (which invalidates on its own). A write run directly via
|
|
418
|
+
# tx.execute() would otherwise leave stale cached reads for that
|
|
419
|
+
# table. Cheap when the cache is empty (the common case).
|
|
420
|
+
if self.__query_cache:
|
|
421
|
+
op = _classify_sql(sql)
|
|
422
|
+
if op in ("INSERT", "UPDATE", "DELETE", "DDL"):
|
|
423
|
+
tbl = _extract_table_name(sql)
|
|
424
|
+
self.invalidate_cache(tbl) # tbl=None clears the whole cache
|
|
312
425
|
|
|
313
426
|
# R12 — Slow query logging.
|
|
314
427
|
if _SLOW_QUERY_MS and elapsed_ms > _SLOW_QUERY_MS:
|
|
@@ -395,6 +508,8 @@ class Transaction:
|
|
|
395
508
|
except Exception as e:
|
|
396
509
|
raise self.engine.process_error(e, sql, args_list)
|
|
397
510
|
|
|
511
|
+
# Rows are now pending in an open transaction.
|
|
512
|
+
self._in_progress = True
|
|
398
513
|
return total
|
|
399
514
|
|
|
400
515
|
def server_execute(self, sql, parms=None):
|
|
@@ -429,7 +544,18 @@ class Transaction:
|
|
|
429
544
|
if self.connection:
|
|
430
545
|
if debug:
|
|
431
546
|
print(f"{id(self)} --- connection commit.")
|
|
432
|
-
|
|
547
|
+
try:
|
|
548
|
+
self.connection.commit()
|
|
549
|
+
except Exception as e:
|
|
550
|
+
# Serialization failures (40001), deadlocks (40P01) and deferred
|
|
551
|
+
# constraint violations are reported at COMMIT, not at statement
|
|
552
|
+
# time. Route them through the engine's classifier so they become
|
|
553
|
+
# the right velocity exception (e.g. DbRetryTransaction) and can
|
|
554
|
+
# be retried by exec_function's envelope — instead of escaping as
|
|
555
|
+
# a raw driver error after the retry loop has already exited.
|
|
556
|
+
raise self.engine.process_error(e)
|
|
557
|
+
# Work is durably committed; the transaction is clean again.
|
|
558
|
+
self._in_progress = False
|
|
433
559
|
if self._query_count:
|
|
434
560
|
_logger.debug(
|
|
435
561
|
"Transaction commit: %d queries in %.1f ms",
|
|
@@ -448,6 +574,8 @@ class Transaction:
|
|
|
448
574
|
if debug:
|
|
449
575
|
print(f"{id(self)} --- connection rollback.")
|
|
450
576
|
self.connection.rollback()
|
|
577
|
+
# Transaction is clean again — no uncommitted work pending.
|
|
578
|
+
self._in_progress = False
|
|
451
579
|
|
|
452
580
|
def create_savepoint(self, sp=None, cursor=None):
|
|
453
581
|
"""
|
|
@@ -476,6 +604,49 @@ class Transaction:
|
|
|
476
604
|
if sql:
|
|
477
605
|
self._execute(sql, vals, cursor=cursor)
|
|
478
606
|
|
|
607
|
+
@contextmanager
|
|
608
|
+
def savepoint(self, name=None):
|
|
609
|
+
"""Context manager for a nested savepoint (attempt-and-recover).
|
|
610
|
+
|
|
611
|
+
On a clean exit the savepoint is released. If the body raises, the
|
|
612
|
+
transaction is rolled back **to the savepoint** — undoing only the work
|
|
613
|
+
inside the block — and the exception is re-raised, leaving the outer
|
|
614
|
+
transaction usable.
|
|
615
|
+
|
|
616
|
+
This is the safe way to try a fallible operation inside a transaction on
|
|
617
|
+
PostgreSQL. Without a savepoint, the first error aborts the *whole*
|
|
618
|
+
transaction and every later statement fails with "current transaction is
|
|
619
|
+
aborted, commands ignored until end of transaction block"; a plain
|
|
620
|
+
``try/except`` around a bare ``execute`` does **not** recover from that.
|
|
621
|
+
::
|
|
622
|
+
|
|
623
|
+
with engine.transaction() as tx:
|
|
624
|
+
try:
|
|
625
|
+
with tx.savepoint():
|
|
626
|
+
tx.table("x").insert(maybe_dup)
|
|
627
|
+
except exceptions.DbDuplicateKeyError:
|
|
628
|
+
pass # tx is still usable here
|
|
629
|
+
tx.table("y").insert(...) # succeeds
|
|
630
|
+
|
|
631
|
+
Args:
|
|
632
|
+
name: Optional savepoint name; a random one is generated if omitted.
|
|
633
|
+
"""
|
|
634
|
+
sp = self.create_savepoint(name)
|
|
635
|
+
try:
|
|
636
|
+
yield sp
|
|
637
|
+
except Exception:
|
|
638
|
+
# Roll back only the inner work and clear the aborted state so the
|
|
639
|
+
# outer transaction can continue. Guard cleanup so a dead connection
|
|
640
|
+
# can't mask the original error.
|
|
641
|
+
try:
|
|
642
|
+
self.rollback_savepoint(sp)
|
|
643
|
+
self.release_savepoint(sp)
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
raise
|
|
647
|
+
else:
|
|
648
|
+
self.release_savepoint(sp)
|
|
649
|
+
|
|
479
650
|
def mark_external_side_effect(self):
|
|
480
651
|
"""Record that this transaction performed an irreversible external side
|
|
481
652
|
effect (a payment charge/capture, an email send, an external API write).
|
|
@@ -559,10 +730,14 @@ class Transaction:
|
|
|
559
730
|
self._json_columns_cache.clear()
|
|
560
731
|
return
|
|
561
732
|
self._json_columns_cache.pop(table_name, None)
|
|
562
|
-
#
|
|
563
|
-
|
|
733
|
+
# R33 — Whole-word match on the SQL text (first element of the key), not
|
|
734
|
+
# a substring: a substring match would wrongly evict "users" when asked
|
|
735
|
+
# to invalidate "user" (and vice-versa). \b boundaries treat quotes /
|
|
736
|
+
# dots / whitespace around an identifier as delimiters while keeping
|
|
737
|
+
# "order" from matching "order_items" (underscore is a word char).
|
|
738
|
+
pattern = re.compile(r"\b" + re.escape(table_name.lower()) + r"\b")
|
|
564
739
|
to_remove = [
|
|
565
|
-
k for k in self.__query_cache if
|
|
740
|
+
k for k in self.__query_cache if pattern.search(k[0].lower())
|
|
566
741
|
]
|
|
567
742
|
for k in to_remove:
|
|
568
743
|
del self.__query_cache[k]
|
|
@@ -204,5 +204,7 @@ tests/test_store_user_data.py
|
|
|
204
204
|
tests/test_sys_modified_count_postgres_demo.py
|
|
205
205
|
tests/test_table_alter.py
|
|
206
206
|
tests/test_transaction_class_wrapping.py
|
|
207
|
+
tests/test_transaction_commit_and_ownership.py
|
|
208
|
+
tests/test_transaction_edge_cases.py
|
|
207
209
|
tests/test_where_clause_validation.py
|
|
208
210
|
tests/test_write_hook_create_flow.py
|