Skip to content

Commit eeb9f33

Browse files
authored
Merge pull request #85 from ScilifelabDataCentre/worker_config
SQ-879: Create a worker config
2 parents dc21054 + d3d9911 commit eeb9f33

33 files changed

Lines changed: 338 additions & 315 deletions

docker/divbase_compose.tests.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ services:
99
- internal
1010
worker-quick:
1111
environment:
12+
- DIVBASE_ENV=test
1213
- S3_ENDPOINT_URL=http://host.docker.internal:9002
1314
ports: !override
1415
- "8111:8101" # metrics server port
@@ -19,6 +20,7 @@ services:
1920
- internal
2021
worker-long:
2122
environment:
23+
- DIVBASE_ENV=test
2224
- S3_ENDPOINT_URL=http://host.docker.internal:9002
2325
ports: !override
2426
- "8112:8101" # metrics server port (fixed: container port is 8001, not 8002)

docker/divbase_compose.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,16 @@ services:
5151
db-migrator:
5252
condition: service_completed_successfully
5353
environment: &worker-env
54+
- DIVBASE_ENV=local_dev
5455
- CELERY_BROKER_URL=pyamqp://celery_user:badpassword@rabbitmq:5672//
5556
- CELERY_RESULT_BACKEND=db+postgresql://divbase_user:badpassword@postgres:5432/divbase_db
5657
- SYNC_DATABASE_URL=postgresql+psycopg2://divbase_user:badpassword@postgres:5432/divbase_db
5758
- S3_ENDPOINT_URL=http://host.docker.internal:9000
5859
- S3_PRESIGNING_URL=http://localhost:9000
5960
- S3_SERVICE_ACCOUNT_ACCESS_KEY=s3-service-account
6061
- S3_SERVICE_ACCOUNT_SECRET_KEY=badpassword
61-
- ENABLE_WORKER_METRICS=False # Enable metrics collection and metrics server in the worker. Optional, as it comes with some overhead.
62-
- ENABLE_WORKER_METRICS_PER_TASK=False # Enable per-task metrics collection in the worker. Optional, as it comes with some overhead.
62+
- ENABLE_WORKER_METRICS=0 # Disable metrics collection and metrics server in the worker as it comes with some overhead.
63+
- ENABLE_WORKER_METRICS_PER_TASK=0 # Disable per-task metrics collection in the worker as it comes with some overhead.
6364
develop: &watch-celery-src
6465
watch:
6566
- action: sync+restart

docs/development/celery_task_implementation.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ For a user-submitted task to be fully integrated in DivBase, it needs to be impl
4747
- Define the task arguments and their type hints just like any other Python function. There are no mandatory args for DivBase tasks, but there are some patterns that can be reused. For instance, if the task logic will interact with S3, add `bucket_name: str,` Tasks that interact with VCF dimensions table need `project_id: int,` for the `get_vcf_metadata_by_project()` helper function and `project_name: str,` for exceptions.
4848
- The logic inside the task can be anything, but typically include reading file(s) from S3 and acting on them.
4949
- Divbase tasks can include database transaction (see next bullet below for more details), but if the task only does a database CRUD it would be better to implement that as an async database CRUD in the API layer rather than a Celery task since that is likely faster and does not require a Celery worker.
50-
- There are existing helper functions for S3 operations (e.g. `create_s3_file_manager()`, `_download_vcf_files()`, `_delete_job_files_from_worker()`) and VCF dimensions metadata table transactions (e.g. `get_vcf_metadata_by_project()`, `get_skipped_vcfs_by_project_worker()`,`create_or_update_vcf_metadata()`). Look at previous tasks to see if there are existing helper functions or logic that can be reused.
50+
- There are existing helper functions for S3 operations (e.g. `_create_s3_file_manager()`, `_download_vcf_files()`, `_delete_job_files_from_worker()`) and VCF dimensions metadata table transactions (e.g. `get_vcf_metadata_by_project()`, `get_skipped_vcfs_by_project_worker()`,`create_or_update_vcf_metadata()`). Look at previous tasks to see if there are existing helper functions or logic that can be reused.
5151
- Database transactions can be called from the task using the `SyncSessionLocal()` sessionmaker instance from SQLAlchemy. There is no point in trying to use async database sessions in DivBase Celery tasks since tasks are run in dedicated worker containers/pods and inside a Celery event pool (see the Celery docs for `prefork` pools). Furthermore, since Celery does not provide a simple way to use the async event loops from SQLAlchemy. Experiments during DivBase development showed that it is technically possible to implement workarounds to use async db sessions in tasks executed by Celery workers, but that it can easily result in event pool issues and errors.
5252
- Raise errors in the task function (catching any errors proprageted from potential helper fuctions called by the task). This assures that `CeleryTaskMeta` is correctly updated with task status `FAILURE` and the error message. This assures that the task history CLI command correctly prints this information in the user's terminal. If errors are returned in any other way that with a `raise`, there is a risk that the status of the task becomes `SUCESS` even if it exited due to errors.
5353
- Tasks end with a `return` statement. This is written to the `results` field in `CeleryTaskMeta`. Celery will use `pickle` to serialize the results for storage in the `CeleryTaskMeta` table.
@@ -377,7 +377,7 @@ Example of a DivBase cron task definition:
377377

378378
```python
379379
@app.task(name="cron_tasks.cleanup_old_task_history")
380-
def cleanup_old_task_history_task(retention_days: int = TASK_RETENTION_DAYS):
380+
def cleanup_old_task_history_task(retention_days: int = worker_settings.cron.task_retention_days):
381381
"""
382382
Periodic task to clean up old task history entries from both TaskHistoryDB and CeleryTaskMeta.
383383
Runs daily to remove entries older than retention_days.
@@ -436,7 +436,7 @@ app.conf.beat_schedule = {
436436
"schedule": crontab(
437437
hour=5, minute=0
438438
), # Run daily at 5 AM CET (timezone defined in app in tasks.py). Don't set to 2 AM or 3 AM due to daylight saving
439-
"kwargs": {"retention_days": TASK_RETENTION_DAYS},
439+
"kwargs": {"retention_days": worker_settings.cron.task_retention_days},
440440
},
441441
"cleanup-stuck-tasks-daily": {
442442
"task": "cron_tasks.cleanup_stuck_tasks",

packages/divbase-api/src/divbase_api/api_config.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919

2020

2121
@dataclass
22-
class APISettings:
23-
"""API configuration settings."""
22+
class GeneralSettings:
23+
"""General API configuration settings."""
2424

2525
environment: str = os.getenv("DIVBASE_ENV", "NOT_SET")
2626
frontend_base_url: str = os.getenv("FRONTEND_BASE_URL", "NOT_SET")
@@ -127,10 +127,10 @@ def __post_init__(self):
127127

128128

129129
@dataclass
130-
class Settings:
130+
class APISettings:
131131
"""Configuration settings for DivBase API."""
132132

133-
api: APISettings = field(default_factory=APISettings)
133+
general: GeneralSettings = field(default_factory=GeneralSettings)
134134
database: DBSettings = field(default_factory=DBSettings)
135135
s3: S3Settings = field(default_factory=S3Settings)
136136
jwt: JWTSettings = field(default_factory=JWTSettings)
@@ -139,14 +139,14 @@ class Settings:
139139
def validate_api_settings(self) -> None:
140140
"""
141141
Validate all required settings are actually set.
142-
This is run on startup of the API which means that later on in the codebase
142+
This is run on startup of the API (lifespan event) which means that later on in the codebase
143143
we don't have to check for any non set values, we can just assume they are set.
144144
"""
145145
required_fields = {
146-
"DIVBASE_ENV": self.api.environment,
147-
"FRONTEND_BASE_URL": self.api.frontend_base_url,
148-
"MKDOCS_SITE_URL": self.api.mkdocs_site_url,
149-
"USER_SUPPORT_EMAIL": self.api.user_support_email,
146+
"DIVBASE_ENV": self.general.environment,
147+
"FRONTEND_BASE_URL": self.general.frontend_base_url,
148+
"MKDOCS_SITE_URL": self.general.mkdocs_site_url,
149+
"USER_SUPPORT_EMAIL": self.general.user_support_email,
150150
"ASYNC_DATABASE_URL": self.database.url,
151151
"SYNC_DATABASE_URL": self.database.sync_url,
152152
"JWT_SECRET_KEY": self.jwt.secret_key,
@@ -161,11 +161,14 @@ def validate_api_settings(self) -> None:
161161
if isinstance(setting, SecretStr):
162162
if setting.get_secret_value() == "NOT_SET":
163163
raise ValueError(f"A required environment variable was not set: {setting_name=}")
164-
if self.api.environment not in LOCAL_DEV_ENVIRONMENTS and setting.get_secret_value() == "badpassword":
164+
if (
165+
self.general.environment not in LOCAL_DEV_ENVIRONMENTS
166+
and setting.get_secret_value() == "badpassword"
167+
):
165168
raise ValueError(
166169
f"A secret environment variable was set to badpassword for a non local environment: {setting_name=}"
167170
)
168171

169172

170173
# This instance can be imported and used throughout the application.
171-
settings = Settings()
174+
api_settings = APISettings()

packages/divbase-api/src/divbase_api/crud/announcements.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from sqlalchemy import select
1414
from sqlalchemy.ext.asyncio import AsyncSession
1515

16-
from divbase_api.api_config import settings
16+
from divbase_api.api_config import api_settings
1717
from divbase_api.models.announcements import AnnouncementDB, AnnouncementTarget
1818
from divbase_api.services.validate_cli_versions import cli_update_available
1919
from divbase_lib.api_schemas.announcements import AnnouncementResponse
@@ -48,9 +48,9 @@ def new_cli_version_announcement(user_cli_version: str) -> AnnouncementResponse
4848
heading="A new version of DivBase CLI is available",
4949
message=(
5050
f"You are using an outdated version of the DivBase CLI '{user_cli_version}'. "
51-
f"Please consider upgrading to the latest version '{settings.api.latest_cli_version}' for new features, bug fixes, and improved security.\n"
51+
f"Please consider upgrading to the latest version '{api_settings.general.latest_cli_version}' for new features, bug fixes, and improved security.\n"
5252
"If you're not sure how to do that, you can find instructions on how to upgrade here: "
53-
f"{settings.api.mkdocs_site_url}/user-guides/installation"
53+
f"{api_settings.general.mkdocs_site_url}/user-guides/installation"
5454
),
5555
level="info",
5656
)

packages/divbase-api/src/divbase_api/crud/s3.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,26 @@
22
Crud operations for the S3 endpoints
33
"""
44

5-
from divbase_api.api_config import settings
5+
from divbase_api.api_config import api_settings
6+
from divbase_api.services.pre_signed_urls import S3PreSignedService
67
from divbase_api.services.s3_client import S3FileManager
78
from divbase_lib.api_schemas.s3 import FileChecksumResponse
89

910

11+
def get_s3_file_manager() -> S3FileManager:
12+
"""Dependency to get an S3FileManager instance. Dependency injected into FastAPI endpoints."""
13+
return S3FileManager(
14+
url=api_settings.s3.endpoint_url,
15+
access_key=api_settings.s3.access_key.get_secret_value(),
16+
secret_key=api_settings.s3.secret_key.get_secret_value(),
17+
)
18+
19+
20+
def get_pre_signed_service() -> S3PreSignedService:
21+
"""Dependency to get pre-signed S3 service. Dependency injected into FastAPI endpoints."""
22+
return S3PreSignedService()
23+
24+
1025
def get_s3_checksums(bucket_name: str, files_to_check: list[str]) -> list[FileChecksumResponse]:
1126
"""
1227
Given a list of object names, return a list of their names and checksums for those that exist in S3.
@@ -16,11 +31,7 @@ def get_s3_checksums(bucket_name: str, files_to_check: list[str]) -> list[FileCh
1631
If we consider a list_objects_v2 approach we have to think carefully about pagination there,
1732
other we make a lot of calls to S3 anyway.
1833
"""
19-
s3_file_manager = S3FileManager(
20-
url=settings.s3.endpoint_url,
21-
access_key=settings.s3.access_key.get_secret_value(),
22-
secret_key=settings.s3.secret_key.get_secret_value(),
23-
)
34+
s3_file_manager = get_s3_file_manager()
2435

2536
response = []
2637
for file_name in files_to_check:

packages/divbase-api/src/divbase_api/db.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from sqlalchemy import text
1313
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
1414

15-
from divbase_api.api_config import settings
15+
from divbase_api.api_config import api_settings
1616
from divbase_api.crud.users import create_user, get_all_users
1717
from divbase_api.schemas.users import UserCreate
1818

@@ -22,8 +22,8 @@
2222
# NOTE: The creation of the AsyncSessionLocal should only occur once, when the module is loaded.
2323
# AKA: Do not refactor to have these in functions.
2424
engine = create_async_engine(
25-
url=settings.database.url.get_secret_value(),
26-
echo=settings.database.echo_db_output,
25+
url=api_settings.database.url.get_secret_value(),
26+
echo=api_settings.database.echo_db_output,
2727
pool_pre_ping=True,
2828
)
2929
AsyncSessionLocal = async_sessionmaker(
@@ -90,8 +90,8 @@ async def create_first_admin_user() -> None:
9090
1. no admin users already exists in the db.
9191
2. FIRST_ADMIN_EMAIL and FIRST_ADMIN_PASSWORD env vars are set.
9292
"""
93-
admin_email = settings.api.first_admin_email
94-
admin_password = settings.api.first_admin_password
93+
admin_email = api_settings.general.first_admin_email
94+
admin_password = api_settings.general.first_admin_password
9595

9696
if admin_email == "NOT_SET" or admin_password.get_secret_value() == "NOT_SET":
9797
logger.info(

packages/divbase-api/src/divbase_api/deps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
- get_current_user_from_cookie for frontend based routes.
1111
1212
The dependencies that depend on this can use e.g. get_current_user as a sub-dependency,
13-
so all checks in the sub-dependancy function are ran when you use this dependency.
13+
so all checks in the sub-dependency function are ran when you use this dependency.
1414
(see here: https://fastapi.tiangolo.com/yo/advanced/security/oauth2-scopes/#dependency-tree-and-scopes)
1515
"""
1616

@@ -157,7 +157,7 @@ async def get_current_admin_user(current_user: Annotated[UserDB, Depends(get_cur
157157
Verify current user has admin access.
158158
159159
Note: This function works by using a sub-dependency to get the current user,
160-
so all the checks in the sub-dependancy function are ran when you use this dependency.
160+
so all the checks in the sub-dependency function are ran when you use this dependency.
161161
(see here: https://fastapi.tiangolo.com/yo/advanced/security/oauth2-scopes/#dependency-tree-and-scopes)
162162
"""
163163
if not current_user.is_admin:

packages/divbase-api/src/divbase_api/divbase_api.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
from divbase_api import __version__ as divbase_version
1616
from divbase_api.admin_panel import register_admin_panel
17-
from divbase_api.api_config import LOCAL_DEV_ENVIRONMENTS, settings
17+
from divbase_api.api_config import LOCAL_DEV_ENVIRONMENTS, api_settings
1818
from divbase_api.db import (
1919
check_db_migrations_up_to_date,
2020
create_first_admin_user,
@@ -36,7 +36,7 @@
3636
from divbase_api.routes.task_history import task_history_router
3737
from divbase_api.routes.vcf_dimensions import vcf_dimensions_router
3838

39-
logging.basicConfig(level=settings.api.log_level, handlers=[logging.StreamHandler(sys.stderr)])
39+
logging.basicConfig(level=api_settings.general.log_level, handlers=[logging.StreamHandler(sys.stderr)])
4040

4141
logger = logging.getLogger(__name__)
4242

@@ -47,7 +47,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
4747
# startup
4848
logger.info("Starting up DivBase API...")
4949

50-
settings.validate_api_settings()
50+
api_settings.validate_api_settings()
5151
logger.info("All API settings are correctly set.")
5252

5353
if not await health_check_db():
@@ -94,7 +94,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
9494
app.include_router(s3_router, prefix="/api/v1/s3", tags=["s3"])
9595
app.include_router(task_history_router, prefix="/api/v1/task-history", tags=["task-history"])
9696
app.include_router(vcf_dimensions_router, prefix="/api/v1/vcf-dimensions", tags=["vcf-dimensions"])
97-
if settings.api.environment in LOCAL_DEV_ENVIRONMENTS:
97+
if api_settings.general.environment in LOCAL_DEV_ENVIRONMENTS:
9898
# not needed in deployed enviroments, so no need to expose it.
9999
app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"])
100100

packages/divbase-api/src/divbase_api/frontend_routes/auth.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from pydantic import SecretStr, ValidationError
1010
from sqlalchemy.ext.asyncio import AsyncSession
1111

12-
from divbase_api.api_config import settings
12+
from divbase_api.api_config import api_settings
1313
from divbase_api.api_constants import KNOWN_JOB_ROLES, SWEDISH_UNIVERSITIES
1414
from divbase_api.crud.auth import (
1515
authenticate_user,
@@ -215,7 +215,7 @@ def registration_failed_response(error_message: str):
215215
return templates.TemplateResponse(
216216
request=request,
217217
name="auth_pages/register_success.html",
218-
context={"name": user_data.name, "email": user_data.email, "from_email": settings.email.from_email},
218+
context={"name": user_data.name, "email": user_data.email, "from_email": api_settings.email.from_email},
219219
)
220220

221221

@@ -374,7 +374,7 @@ async def post_forgot_password_form(
374374
"""Handle forgot password form submission to send a password reset email."""
375375
RESET_LINK_SENT_MSG = (
376376
f"If your account exists, and your email is verified, a password reset email has been sent to {email}. Please check your inbox."
377-
+ f"The email will be sent from {settings.email.from_email}."
377+
+ f"The email will be sent from {api_settings.email.from_email}."
378378
)
379379

380380
user = await get_user_by_email(db=db, email=email)

0 commit comments

Comments
 (0)