Skip to content

Commit a8057ac

Browse files
authored
Merge pull request #81 from ScilifelabDataCentre/celery-pause
SQ787: Prevent adding new jobs to the queue prior to doing migrations/upgrades
2 parents 8e89d31 + f80c1ef commit a8057ac

12 files changed

Lines changed: 359 additions & 4 deletions

File tree

docs/development/celery_task_implementation.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ class TaskHistoryResult(BaseModel):
185185
```
186186

187187
- The dependency injection in `project_and_user_and_role` and the helper function `has_required_role` are used to connect to the database and check that the user has permission to submit tasks to this project. See the example below for how to use it in an endpoint function.
188+
- Tasks should also call the `check_queue_closed_for_new_tasks` fn to validate the queue is not down (i.e. due to planned maintenance). This function raises a `QueueClosedError` if the queue is closed, which is handled in the API layer to return a 400 error with a message to the user.
188189
- To enqueue the task function defined in `tasks.py` in the job system, use `result = <TASK_FUNCTION>.apply_async()`. This returns some initial Celery task metadata, including the Celery task UUID.
189190
- The established pattern in DivBase is - for clarity - to call `.apply_async()` with keyword arguments and not arguments. Specifically, the Pydantic kwargs model can be populated, and then dumped in to Python dict in `.apply_async()` since Celery cannot de/serialise Pydantic mdoels. This ensures that kwarg types are validated before enqueuing the task.
190191
- Call the `create_task_history_entry()` CRUD function to record the Celery task UUID in `TaskHistoryDB` along with user and project ID. This function will return the DivBase task ID, which is the autoincrementing id from the postgreSQL table. This is an integer and much easier for the users to handle than long UUIDs.
@@ -211,6 +212,9 @@ async def create_bcftools_jobs(
211212
if not has_required_role(role, ProjectRoles.EDIT):
212213
raise AuthorizationError("You don't have permission to query this project.")
213214

215+
# validation queue is not closed, will raise custom error to user if so.
216+
await check_queue_closed_for_new_tasks(db=db, is_admin=current_user.is_admin)
217+
214218
# Pack the required task arguments in the corresponding Pydantic model (see Section 2.2.)
215219
# This ensures that the kwargs are type validated
216220
task_kwargs = BcftoolsQueryKwargs(

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from divbase_api.models.announcements import AnnouncementDB, AnnouncementLevel, AnnouncementTarget
4141
from divbase_api.models.project_versions import ProjectVersionDB
4242
from divbase_api.models.projects import ProjectDB, ProjectMembershipDB, ProjectRoles
43+
from divbase_api.models.queue_status import QueueStatus
4344
from divbase_api.models.revoked_tokens import RevokedTokenDB, TokenRevokeReason
4445
from divbase_api.models.task_history import CeleryTaskMeta, TaskHistoryDB, TaskStartedAtDB
4546
from divbase_api.models.users import UserDB
@@ -628,6 +629,101 @@ class AnnouncementView(ModelView):
628629
exclude_fields_from_detail = []
629630

630631

632+
class QueueStatusView(ModelView):
633+
"""
634+
Custom admin panel View for the QueueStatus model.
635+
636+
This is a singleton table (only 1 row allowed).
637+
Deletion and creation are disabled - only editing the existing row is allowed.
638+
"""
639+
640+
page_size_options = PAGINATION_DEFAULTS
641+
fields = [
642+
IntegerField("id", disabled=True),
643+
BooleanField(
644+
"is_closed",
645+
help_text=(
646+
"Is the queue closed for new tasks? "
647+
"If the scheduled start time is in the past or not set, the queue is closed. "
648+
"If scheduled start time is in the future, queue will be closed at that time."
649+
),
650+
),
651+
DateTimeField(
652+
"scheduled_start",
653+
label="Scheduled closure start time (UTC time)",
654+
help_text=(
655+
"(THIS IS UTC TIME) Optional: When should the queue closure set above take effect? "
656+
"Leave empty for queue to be closed immediatialy "
657+
"If the queue is open and you set this field, it will do nothing."
658+
),
659+
),
660+
TextAreaField(
661+
"reason_for_users",
662+
help_text=(
663+
"Message shown to users when the queue is closed (max 500 characters). "
664+
"You could write: "
665+
"The queuing system is currently closed for new tasks due to planned upcoming maintenance. Please try again later."
666+
),
667+
),
668+
DateTimeField(
669+
"created_at",
670+
label="Created At (UTC time)",
671+
help_text="Timestamp when the entry was created. Value determined by system.",
672+
disabled=True,
673+
),
674+
DateTimeField(
675+
"updated_at",
676+
label="Updated At (UTC time)",
677+
help_text="Timestamp when the entry was last updated. Value determined by system.",
678+
disabled=True,
679+
),
680+
]
681+
682+
exclude_fields_from_list = []
683+
exclude_fields_from_edit = ["id", "created_at", "updated_at"]
684+
exclude_fields_from_detail = []
685+
686+
def can_delete(self, request: Request) -> bool:
687+
"""Disable deletion - this is a singleton table."""
688+
return False
689+
690+
def can_create(self, request: Request) -> bool:
691+
"""Disable creation - this is a singleton table with only 1 row."""
692+
# 1st row created by the alembic migration
693+
return False
694+
695+
async def edit(self, request: Request, pk: Any, data: dict) -> Any:
696+
"""
697+
Override the default edit method to ensure that the `scheduled_start` field is set to None
698+
if `is_closed` is set to False.
699+
"""
700+
if not data.get("is_closed"):
701+
data["scheduled_start"] = None
702+
703+
return await super().edit(request=request, pk=pk, data=data)
704+
705+
async def validate(self, request: Request, data: dict[str, Any]) -> None:
706+
errors: dict[str, str] = {}
707+
708+
if data.get("scheduled_start") and not data.get("is_closed"):
709+
errors["scheduled_start"] = "Cannot have a scheduled start time without the queue being closed."
710+
711+
reason = data.get("reason_for_users") or ""
712+
if data.get("is_closed") and not reason.strip():
713+
errors["reason_for_users"] = "Reason for users is required when the queue is closed."
714+
715+
if len(reason) > 500:
716+
errors["reason_for_users"] = "Message must be 500 characters or less."
717+
718+
if errors:
719+
raise FormValidationError(errors=errors)
720+
721+
return await super().validate(request=request, data=data)
722+
723+
# NOTE, no serialize_field_value/_format_cet_datetime override on purpose here
724+
# as does not work well when modifying the timestamp in edit view, easier to just display as UTC
725+
726+
631727
class DivBaseAuthProvider(AuthProvider):
632728
"""
633729
This class enables starlette-admin to make use of DivBase's pre-existing auth system.
@@ -711,4 +807,5 @@ def register_admin_panel(app: FastAPI, engine: AsyncEngine) -> None:
711807
admin.add_view(
712808
AnnouncementView(AnnouncementDB, icon="fas fa-bullhorn", label="Announcements", identity="announcement")
713809
)
810+
admin.add_view(QueueStatusView(QueueStatus, icon="fas fa-power-off", label="Queue Status", identity="queue-status"))
714811
admin.mount_to(app)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
Get the current status of the queuing system as defined in the QueueStatus model.
3+
4+
This is used to determine if we should reject new tasks from being created when the queue is closed for/ahead of planned maintenance.
5+
6+
CREATE, EDIT and DELETE all handled by admin panel.
7+
"""
8+
9+
from datetime import datetime
10+
11+
from sqlalchemy import select
12+
from sqlalchemy.ext.asyncio import AsyncSession
13+
14+
from divbase_api.exceptions import QueueClosedError
15+
from divbase_api.models.queue_status import QueueStatus
16+
17+
18+
async def check_queue_closed_for_new_tasks(db: AsyncSession, is_admin: bool) -> None:
19+
"""
20+
Checks if the queue is closed for new tasks based on the QueueStatus model.
21+
22+
Will raise a QueueClosedError if so.
23+
"""
24+
if is_admin:
25+
# admins can always submit new tasks to allow for testing and validation during maintenance periods
26+
return
27+
28+
result = await db.execute(select(QueueStatus).filter_by(id=1))
29+
status = result.scalar_one_or_none()
30+
31+
if not status or not status.is_closed:
32+
return
33+
34+
if not status.scheduled_start: # effective immediately if no scheduled start time
35+
raise QueueClosedError(message=status.reason_for_users)
36+
37+
# if the queue is scheduled to be closed in the future, we can still allow new tasks to be created until that time
38+
if status.scheduled_start < datetime.now(tz=status.scheduled_start.tzinfo):
39+
raise QueueClosedError(message=status.reason_for_users)

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
ProjectVersionCreationError,
3131
ProjectVersionNotFoundError,
3232
ProjectVersionSoftDeletedError,
33+
QueueClosedError,
3334
TaskNotFoundInBackendError,
3435
TooManyObjectsInRequestError,
3536
UserRegistrationError,
@@ -378,6 +379,18 @@ async def object_does_not_exist_error_handler(request: Request, exc: ObjectDoesN
378379
)
379380

380381

382+
async def queue_closed_error_handler(request: Request, exc: QueueClosedError):
383+
logger.debug(f"Queue closed error for {request.method} {request.url.path}: {exc.message}", exc_info=False)
384+
if is_api_request(request):
385+
return JSONResponse(
386+
status_code=exc.status_code,
387+
content={"detail": exc.message, "type": "queue_closed_error"},
388+
headers=exc.headers,
389+
)
390+
else:
391+
return await render_error_page(request, exc.message, status_code=exc.status_code)
392+
393+
381394
def register_exception_handlers(app: FastAPI) -> None:
382395
"""
383396
Register all exception handlers with FastAPI app.
@@ -402,6 +415,7 @@ def register_exception_handlers(app: FastAPI) -> None:
402415
app.add_exception_handler(TaskNotFoundInBackendError, task_not_found_in_backend_error_handler) # type: ignore
403416
app.add_exception_handler(DownloadedFileChecksumMismatchError, downloaded_file_checksum_mismatch_error_handler) # type: ignore
404417
app.add_exception_handler(ObjectDoesNotExistError, object_does_not_exist_error_handler) # type: ignore
418+
app.add_exception_handler(QueueClosedError, queue_closed_error_handler) # type: ignore
405419

406420
# These cover more generic/unexpected HTTP errors - the exceptions above take precedence
407421
app.add_exception_handler(HTTPException, generic_http_exception_handler) # type: ignore

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,10 @@ def __init__(self, filename: str | None = None, project_name: str | None = None)
162162
"Please check that you have spelled the file name correctly and that the file has been uploaded to the project."
163163
)
164164
super().__init__(message=message, status_code=status.HTTP_404_NOT_FOUND)
165+
166+
167+
class QueueClosedError(DivBaseAPIException):
168+
"""Raised when the queue is closed for new tasks (e.g. before a maintenance window)."""
169+
170+
def __init__(self, message: str):
171+
super().__init__(message=message, status_code=status.HTTP_400_BAD_REQUEST)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def register_middleware(app: FastAPI) -> None:
8484
"""
8585
Register all middleware to the FastAPI app.
8686
87-
NOTE: When modyfying/extending this function remember that Middleware order is important.
87+
NOTE: When modifying/extending this function remember that Middleware order is important.
8888
8989
So for the request-response cycle:
9090
request -> last added middleware -> ... -> first added middleware -> route handler
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""add_queue_status_db_model
2+
3+
Revision ID: 755a240de0ab
4+
Revises: 03964a15e9ed
5+
Create Date: 2026-03-19 11:57:16.792455
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "755a240de0ab"
16+
down_revision: Union[str, Sequence[str], None] = "03964a15e9ed"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
# NOTE: The 'id' is set to autoincrement, which is not actually needed
25+
# This is because we are inheriting it from basedb model and it does no harm to have autoincrement=true.
26+
op.create_table(
27+
"queue_status",
28+
sa.Column("is_closed", sa.Boolean(), nullable=False),
29+
sa.Column("scheduled_start", sa.DateTime(timezone=True), nullable=True),
30+
sa.Column("reason_for_users", sa.String(length=500), nullable=False),
31+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
32+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
33+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
34+
sa.CheckConstraint("id = 1", name="queue_status_singleton_row"),
35+
sa.PrimaryKeyConstraint("id"),
36+
)
37+
op.create_index(op.f("ix_queue_status_id"), "queue_status", ["id"], unique=False)
38+
# ### end Alembic commands ###
39+
### human added - create the initial singleton row
40+
conn = op.get_bind()
41+
conn.execute(
42+
sa.text(
43+
"INSERT INTO queue_status (id, is_closed, reason_for_users) VALUES (1, false, 'The queuing system is currently closed for new tasks due to planned upcoming maintenance. Please try again later.')"
44+
)
45+
)
46+
47+
48+
def downgrade() -> None:
49+
"""Downgrade schema."""
50+
# ### commands auto generated by Alembic - please adjust! ###
51+
op.drop_index(op.f("ix_queue_status_id"), table_name="queue_status")
52+
op.drop_table("queue_status")
53+
# ### end Alembic commands ###

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
from divbase_api.models.base import Base, BaseDBModel
99
from divbase_api.models.project_versions import ProjectVersionDB
1010
from divbase_api.models.projects import ProjectDB, ProjectMembershipDB, ProjectRoles
11+
from divbase_api.models.queue_status import QueueStatus
1112
from divbase_api.models.revoked_tokens import RevokedTokenDB, TokenRevokeReason
1213
from divbase_api.models.task_history import TaskHistoryDB, TaskStartedAtDB
1314
from divbase_api.models.users import UserDB
14-
from divbase_api.models.vcf_dimensions import SkippedVCFDB, VCFMetadataDB
15+
from divbase_api.models.vcf_dimensions import SkippedVCFDB, VCFMetadataDB, VCFMetadataSamplesDB, VCFMetadataScaffoldsDB
1516

1617
__all__ = [
1718
"Base",
@@ -22,13 +23,14 @@
2223
"ProjectMembershipDB",
2324
"VCFMetadataDB",
2425
"SkippedVCFDB",
26+
"VCFMetadataSamplesDB",
27+
"VCFMetadataScaffoldsDB",
2528
"TaskHistoryDB",
2629
"TaskStartedAtDB",
2730
"TokenRevokeReason",
2831
"RevokedTokenDB",
2932
"ProjectVersionDB",
3033
"AnnouncementDB",
3134
"AnnouncementTarget",
32-
"VCFMetadataSamplesDB",
33-
"VCFMetadataScaffoldsDB",
35+
"QueueStatus",
3436
]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
QueueStatus db model is used to define whether the DivBase queuing system is closed to new tasks.
3+
4+
The use case for this is prior to a scheduled upgrade, we may want to empty the queue:
5+
1. (Day before upgrade): Go to admin panel, set is_closed to True (optionally schedule when the queue will be closed).
6+
2. (Day of upgrade): Validate queue is empty, perform upgrade,
7+
3. Once happy, go to admin panel, set is_closed to False to allow new tasks to be created again.
8+
9+
NOTE: Admins can submit jobs even when the queue is closed to allow for validation and testing during the maintenance period
10+
"""
11+
12+
from datetime import datetime
13+
14+
from sqlalchemy import CheckConstraint, DateTime, String
15+
from sqlalchemy.orm import Mapped, mapped_column
16+
17+
from divbase_api.models.base import BaseDBModel
18+
19+
20+
class QueueStatus(BaseDBModel):
21+
__tablename__ = "queue_status"
22+
23+
is_closed: Mapped[bool] = mapped_column(default=False)
24+
scheduled_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
25+
reason_for_users: Mapped[str] = mapped_column(String(500))
26+
27+
# this arg makes the table a singleton, so only 1 row (with id = 1) in the table
28+
__table_args__ = (CheckConstraint("id = 1", name="queue_status_singleton_row"),)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from divbase_api.api_config import settings
1414
from divbase_api.crud.projects import has_required_role
15+
from divbase_api.crud.queue_status import check_queue_closed_for_new_tasks
1516
from divbase_api.crud.task_history import create_task_history_entry, update_task_history_entry_with_celery_task_id
1617
from divbase_api.db import get_db
1718
from divbase_api.deps import get_project_member
@@ -65,6 +66,7 @@ async def sample_metadata_query(
6566

6667
if not has_required_role(role, ProjectRoles.EDIT):
6768
raise AuthorizationError("You don't have permission to query this project.")
69+
await check_queue_closed_for_new_tasks(db=db, is_admin=current_user.is_admin)
6870

6971
task_kwargs = SampleMetadataQueryKwargs(
7072
tsv_filter=sample_metadata_query_request.tsv_filter,
@@ -128,6 +130,7 @@ async def create_bcftools_jobs(
128130

129131
if not has_required_role(role, ProjectRoles.EDIT):
130132
raise AuthorizationError("You don't have permission to query this project.")
133+
await check_queue_closed_for_new_tasks(db=db, is_admin=current_user.is_admin)
131134

132135
job_id = await create_task_history_entry(
133136
user_id=current_user.id,

0 commit comments

Comments
 (0)