This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-atr-experiments.git
The following commit(s) were added to refs/heads/main by this push:
new f08d224 Add the ability to use blockbuster and remove some blocking
calls
f08d224 is described below
commit f08d224058001daae29e9f0e9c32a849607b96e9
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Feb 27 17:24:15 2025 +0200
Add the ability to use blockbuster and remove some blocking calls
---
atr/config.py | 6 +++--
atr/preload.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
atr/routes.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
atr/server.py | 24 ++++++++++++++++--
poetry.lock | 28 ++++++++++++++++++++-
pyproject.toml | 1 +
uv.lock | 20 +++++++++++++++
7 files changed, 229 insertions(+), 6 deletions(-)
diff --git a/atr/config.py b/atr/config.py
index e2d48f2..28b2c2c 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -28,6 +28,8 @@ GB = 1024 * MB
class AppConfig:
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE_DIR = os.path.join(PROJECT_ROOT, "state")
+ DEBUG = False
+ USE_BLOCKBUSTER = False
RELEASE_STORAGE_DIR = os.path.join(STATE_DIR, "releases")
DATA_MODELS_FILE = data_models_file
@@ -54,13 +56,13 @@ class AppConfig:
)
-class ProductionConfig(AppConfig):
- DEBUG = False
+class ProductionConfig(AppConfig): ...
class DebugConfig(AppConfig):
DEBUG = True
TEMPLATES_AUTO_RELOAD = True
+ USE_BLOCKBUSTER = False
# Load all possible configurations
diff --git a/atr/preload.py b/atr/preload.py
new file mode 100644
index 0000000..dffee8b
--- /dev/null
+++ b/atr/preload.py
@@ -0,0 +1,77 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+Prevent blocking file operations from affecting the event loop.
+
+This module prevents blocking file operations in Jinja2 template loading from
+being run in an asynchronous context.
+"""
+
+import asyncio
+import os
+import pathlib
+
+from asfquart.base import QuartApp
+
+
+def preload_templates(app: QuartApp) -> None:
+ """Preload all templates in the templates directory."""
+ # We must disable automatic reload otherwise Jinja will check for
modifications
+ # Checking for modifications means that Jinja will call os.stat() in an
asynchronous context
+ app.jinja_env.auto_reload = False
+
+ template_dir = pathlib.Path(os.path.join(os.path.dirname(os.getcwd()),
"atr", "templates"))
+
+ if not template_dir.exists():
+ print(f"Warning: Template directory {template_dir} does not exist")
+ return
+
+ # Find all template files
+ template_files: list[pathlib.Path] = []
+ for extension in [".html", ".jinja", ".j2", ".txt"]:
+ template_files.extend(template_dir.glob(f"**/*{extension}"))
+
+ # For each template file, get its path relative to the template directory
+ for template_file in template_files:
+ try:
+ relative_path = template_file.relative_to(template_dir)
+ template_name = str(relative_path).replace("\\", "/")
+
+ # Access the template to make Jinja load and cache it
+ app.jinja_env.get_template(template_name)
+ except Exception as e:
+ print(f"Error preloading template {template_file}: {e}")
+ print(f"Preloaded {len(template_files)} templates")
+
+
+def setup_template_preloading(app: QuartApp) -> None:
+ """Register the template preloading to happen before the async loop
starts."""
+ # Store the original before_serving functions
+ original_before_serving = app.before_serving_funcs.copy()
+ app.before_serving_funcs = []
+
+ @app.before_serving
+ async def preload_before_blockbuster() -> None:
+ # Preload all templates
+ # This doesn't really need to be asynchronous
+ # We do this anyway to avoid being ironic
+ await asyncio.to_thread(preload_templates, app)
+
+ # Run all the original before_serving functions
+ for func in original_before_serving:
+ await func()
diff --git a/atr/routes.py b/atr/routes.py
index be5c5e9..339bfba 100644
--- a/atr/routes.py
+++ b/atr/routes.py
@@ -111,11 +111,84 @@ class MicrosecondsFormatter(logging.Formatter):
default_msec_format = "%s.%03d"
+class AsyncFileHandler(logging.Handler):
+ """A logging handler that writes logs asynchronously using aiofiles."""
+
+ def __init__(self, filename, mode="w", encoding=None):
+ super().__init__()
+ self.filename = filename
+
+ if mode != "w":
+ raise RuntimeError("Only write mode is supported")
+
+ self.encoding = encoding
+ self.queue = asyncio.Queue()
+ self.our_worker_task = None
+
+ def our_worker_task_ensure(self):
+ """Lazily create the worker task if it doesn't exist and there's an
event loop."""
+ if self.our_worker_task is None:
+ try:
+ loop = asyncio.get_running_loop()
+ self.our_worker_task = loop.create_task(self.our_worker())
+ except RuntimeError:
+ # No event loop running yet, try again on next emit
+ ...
+
+ async def our_worker(self):
+ """Background task that writes queued log messages to file."""
+ while True:
+ record = await self.queue.get()
+ if record is None:
+ break
+
+ try:
+ # Format the log record first
+ formatted_message = self.format(record) + "\n"
+ message_bytes = formatted_message.encode(self.encoding or
"utf-8")
+
+ # Use a binary mode literal with aiofiles.open
+ #
https://github.com/Tinche/aiofiles/blob/main/src/aiofiles/threadpool/__init__.py
+ # We should be able to use any mode, but pyright requires a
binary mode
+ async with aiofiles.open(self.filename, "wb+") as f:
+ await f.write(message_bytes)
+ except Exception:
+ self.handleError(record)
+ finally:
+ self.queue.task_done()
+
+ def emit(self, record):
+ """Queue the record for writing by the worker task."""
+ try:
+ # Ensure worker task is running
+ self.our_worker_task_ensure()
+
+ # Queue the record, but handle the case where no event loop is
running yet
+ try:
+ self.queue.put_nowait(record)
+ except RuntimeError:
+ # If there's no event loop, log synchronously as fallback
+ with open(self.filename, "w", encoding=self.encoding) as f:
+ f.write(self.format(record) + "\n")
+ except Exception:
+ self.handleError(record)
+
+ def close(self):
+ """Shut down the worker task cleanly."""
+ if self.our_worker_task is not None and not
self.our_worker_task.done():
+ try:
+ self.queue.put_nowait(None)
+ except RuntimeError:
+ # No running event loop, no need to clean up
+ ...
+ super().close()
+
+
# Setup a dedicated logger for route performance metrics
route_logger = logging.getLogger("route.performance")
# Use custom formatter that properly includes microseconds
# TODO: Is this actually UTC?
-route_logger_handler = logging.FileHandler("route-performance.log")
+route_logger_handler = AsyncFileHandler("route-performance.log")
route_logger_handler.setFormatter(MicrosecondsFormatter("%(asctime)s -
%(message)s"))
route_logger.addHandler(route_logger_handler)
route_logger.setLevel(logging.INFO)
@@ -547,6 +620,10 @@ async def package_add_session_process(
async def package_add_validate(
request: Request,
) -> tuple[str, FileStorage, FileStorage | None, FileStorage | None, str]:
+ # This calls quart.wrappers.request.form _load_form_data
+ # Which calls quart.formparser parse and parse_func and parser.parse
+ # Which calls _write which calls tempfile, which is synchronous
+ # It's getting a tempfile back from some prior call
form = await request.form
# TODO: Check that the submitter is a committer of the project
diff --git a/atr/server.py b/atr/server.py
index 0bc01d7..988d701 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -22,6 +22,7 @@ import os
from collections.abc import Iterable
from typing import Any
+from blockbuster import BlockBuster
from decouple import config
from quart_schema import OpenAPIProvider, QuartSchema
from werkzeug.routing import Rule
@@ -31,14 +32,17 @@ import asfquart.generics
import asfquart.session
from asfquart.base import QuartApp
from atr.blueprints import register_blueprints
-from atr.config import AppConfig, config_dict
+from atr.config import AppConfig, DebugConfig, config_dict
from atr.db import create_database
from atr.manager import get_worker_manager
+from atr.preload import setup_template_preloading
# WARNING: Don't run with debug turned on in production!
-DEBUG: bool = config("DEBUG", default=True, cast=bool)
+# TODO: Need to ask @tn how he wants this to work
+DEBUG: bool = config("DEBUG", default=DebugConfig.DEBUG, cast=bool)
# Determine which configuration to use
config_mode = "Debug" if DEBUG else "Production"
+use_blockbuster: bool = config("USE_BLOCKBUSTER",
default=DebugConfig.USE_BLOCKBUSTER, cast=bool)
# Avoid OIDC
asfquart.generics.OAUTH_URL_INIT =
"https://oauth.apache.org/auth?state=%s&redirect_uri=%s"
@@ -160,6 +164,22 @@ def create_app(app_config: type[AppConfig]) -> QuartApp:
app_setup_lifecycle(app)
app_setup_logging(app, config_mode, app_config)
+ # "I'll have a P, please, Bob."
+ blockbuster = BlockBuster()
+ setup_template_preloading(app)
+
+ @app.before_serving
+ async def start_blockbuster() -> None:
+ if DEBUG and use_blockbuster:
+ blockbuster.activate()
+ app.logger.info("Blockbuster activated to detect blocking calls")
+
+ @app.after_serving
+ async def stop_blockbuster() -> None:
+ if DEBUG and use_blockbuster:
+ blockbuster.deactivate()
+ app.logger.info("Blockbuster deactivated")
+
return app
diff --git a/poetry.lock b/poetry.lock
index 6e3bdaf..0460708 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -322,6 +322,21 @@ files = [
{file = "blinker-1.9.0.tar.gz", hash =
"sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"},
]
+[[package]]
+name = "blockbuster"
+version = "1.5.23"
+description = "Utility to detect blocking calls in the async event loop"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "blockbuster-1.5.23-py3-none-any.whl", hash =
"sha256:cf4d9df51d0ba5ac9b0f14594a456e42b7a49dcc35819c6b36805ac285b1f6fe"},
+ {file = "blockbuster-1.5.23.tar.gz", hash =
"sha256:ede6302307e700a60518c99caccfea159485382648e0158131e3506d4ff7b49c"},
+]
+
+[package.dependencies]
+forbiddenfruit = ">=0.1.4"
+
[[package]]
name = "boolean-py"
version = "4.0"
@@ -770,6 +785,17 @@ Werkzeug = ">=3.1"
async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"]
+[[package]]
+name = "forbiddenfruit"
+version = "0.1.4"
+description = "Patch python built-in objects"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "forbiddenfruit-0.1.4.tar.gz", hash =
"sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253"},
+]
+
[[package]]
name = "frozenlist"
version = "1.5.0"
@@ -2746,4 +2772,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.1"
python-versions = "~=3.13"
-content-hash =
"1a93f827762f5267884f42f4ecb06249d951e8d897e242abd9d85ad4ff6b07c0"
+content-hash =
"79260867a4d15fb6f9c7eb985dae7cca7f425f6011baca8dc805615cb1b780f1"
diff --git a/pyproject.toml b/pyproject.toml
index 7658304..be7896c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,7 @@ dependencies = [
"quart-schema~=0.21",
"spdx-tools>=0.8.3,<0.9.0",
"sqlmodel~=0.0",
+ "blockbuster>=1.5.23,<2.0.0",
]
[dependency-groups]
diff --git a/uv.lock b/uv.lock
index d39f6ff..1a3154c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -189,6 +189,18 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl",
hash =
"sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size
= 8458 },
]
+[[package]]
+name = "blockbuster"
+version = "1.5.23"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "forbiddenfruit" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/07/cd/19a5c7cef4ded0d5cab07c3df2195d8f599c33e7c18a362ce059d87ea79a/blockbuster-1.5.23.tar.gz",
hash =
"sha256:ede6302307e700a60518c99caccfea159485382648e0158131e3506d4ff7b49c", size
= 51198 }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/35/5f/7991d1f3b8d91eddabf883bc52f88f7d8c8f556be0d50ee8b0fa078be8a9/blockbuster-1.5.23-py3-none-any.whl",
hash =
"sha256:cf4d9df51d0ba5ac9b0f14594a456e42b7a49dcc35819c6b36805ac285b1f6fe", size
= 13199 },
+]
+
[[package]]
name = "boolean-py"
version = "4.0"
@@ -412,6 +424,12 @@ wheels = [
{ url =
"https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl",
hash =
"sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size
= 102979 },
]
+[[package]]
+name = "forbiddenfruit"
+version = "0.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/e6/79/d4f20e91327c98096d605646bdc6a5ffedae820f38d378d3515c42ec5e60/forbiddenfruit-0.1.4.tar.gz",
hash =
"sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253", size
= 43756 }
+
[[package]]
name = "frozenlist"
version = "1.5.0"
@@ -1176,6 +1194,7 @@ dependencies = [
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "asfquart" },
+ { name = "blockbuster" },
{ name = "cryptography" },
{ name = "greenlet" },
{ name = "httpx" },
@@ -1207,6 +1226,7 @@ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.21.0,<0.22.0" },
{ name = "alembic", specifier = "~=1.14" },
{ name = "asfquart", editable = "asfquart" },
+ { name = "blockbuster", specifier = ">=1.5.23,<2.0.0" },
{ name = "cryptography", specifier = "~=44.0" },
{ name = "greenlet", specifier = ">=3.1.1,<4.0.0" },
{ name = "httpx", specifier = "~=0.27" },
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]