Skip to content

Commit 7d6a610

Browse files
committed
Use job ID, not task ID, in bcftools result files
Db migration needed to make task_id nullable in task_history table.
1 parent b2e329d commit 7d6a610

7 files changed

Lines changed: 80 additions & 15 deletions

File tree

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import logging
66

7-
from sqlalchemy import select
7+
from sqlalchemy import select, update
88
from sqlalchemy.ext.asyncio import AsyncSession
99

1010
from divbase_api.models.projects import ProjectMembershipDB, ProjectRoles
@@ -77,15 +77,36 @@ async def get_tasks_pg(
7777

7878
async def create_task_history_entry(
7979
db: AsyncSession,
80-
task_id: str,
8180
user_id: int,
8281
project_id: int,
82+
task_id: str | None = None,
8383
) -> int:
8484
"""
85-
Create a new task history entry. Returns the primary key ID from the table, which is the user's job id (but different from celery task_id).
85+
Create a new task history entry. Returns the primary key ID from the table, which is the DivBase job ID
86+
(which is an integer rather than the celery task_id which is a UUID).
87+
88+
The task_id parameter is optional to allow creating entries before the celery task is created (e.g., for bcftools pipe tasks).
89+
8690
"""
8791
task_history_entry = TaskHistoryDB(task_id=task_id, user_id=user_id, project_id=project_id)
8892
db.add(task_history_entry)
8993
await db.commit()
9094
await db.refresh(task_history_entry)
9195
return task_history_entry.id
96+
97+
98+
async def update_task_history_entry_with_celery_task_id(
99+
db: AsyncSession,
100+
job_id: int,
101+
task_id: str | None = None,
102+
) -> None:
103+
"""
104+
Updates an existing task history entry with the celery task ID. The intended use case is to first create a table entry without a celery task ID using
105+
create_task_history_entry(), then submit a celery task with .apply_async and get the celery task ID, and then update the table entry with the celery task ID using this function.
106+
"""
107+
108+
stmt = update(TaskHistoryDB).where(TaskHistoryDB.id == job_id).values(task_id=task_id)
109+
result = await db.execute(stmt)
110+
if result.rowcount == 0:
111+
raise ValueError(f"TaskHistoryDB entry with id={job_id} not found")
112+
await db.commit()
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Make task_id nullable in task_history
2+
3+
Revision ID: 3e168ddc857e
4+
Revises: 5b559e3008f4
5+
Create Date: 2026-01-13 09:12:26.855801
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 = "3e168ddc857e"
16+
down_revision: Union[str, Sequence[str], None] = "5b559e3008f4"
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+
op.drop_constraint("task_history_pkey", "task_history", type_="primary")
24+
op.create_primary_key("task_history_pkey", "task_history", ["id"])
25+
op.alter_column("task_history", "task_id", existing_type=sa.VARCHAR(), nullable=True)
26+
27+
28+
def downgrade() -> None:
29+
"""Downgrade schema."""
30+
op.alter_column("task_history", "task_id", existing_type=sa.VARCHAR(), nullable=False)
31+
op.drop_constraint("task_history_pkey", "task_history", type_="primary")
32+
op.create_primary_key("task_history_pkey", "task_history", ["task_id", "id"])

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@
1818
class TaskHistoryDB(BaseDBModel):
1919
"""
2020
DB model for history of tasks executed by Celery.
21+
22+
The ID field inherited from BaseDBModel serves as the primary key for this table and is the DivBase job ID.
23+
The task_id field corresponds to the Celery task ID (UUID string) and is nullable since there are cases such as the bcftools pipe task
24+
that need the DivBase job ID as input, meaning that a table entry is needed before the Celery task is created.
2125
"""
2226

2327
__tablename__ = "task_history"
2428

25-
task_id: Mapped[str] = mapped_column(String, index=True, unique=True)
29+
task_id: Mapped[str | None] = mapped_column(String, index=True, unique=True, nullable=True)
2630
user_id: Mapped[int | None] = mapped_column(
2731
Integer,
2832
ForeignKey("user.id", ondelete="CASCADE"),

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +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.task_history import create_task_history_entry
15+
from divbase_api.crud.task_history import create_task_history_entry, update_task_history_entry_with_celery_task_id
1616
from divbase_api.db import get_db
1717
from divbase_api.deps import get_project_member
1818
from divbase_api.exceptions import AuthorizationError, VCFDimensionsEntryMissingError
@@ -114,6 +114,12 @@ async def create_bcftools_jobs(
114114
if not has_required_role(role, ProjectRoles.EDIT):
115115
raise AuthorizationError("You don't have permission to query this project.")
116116

117+
job_id = await create_task_history_entry(
118+
user_id=current_user.id,
119+
project_id=project.id,
120+
db=db,
121+
)
122+
117123
task_kwargs = BcftoolsQueryKwargs(
118124
tsv_filter=bcftools_query_request.tsv_filter,
119125
command=bcftools_query_request.command,
@@ -122,14 +128,14 @@ async def create_bcftools_jobs(
122128
project_id=project.id,
123129
project_name=project.name,
124130
user_id=current_user.id,
131+
job_id=job_id,
125132
)
126133

127-
results = bcftools_pipe_task.apply_async(kwargs=task_kwargs.model_dump())
134+
result = bcftools_pipe_task.apply_async(kwargs=task_kwargs.model_dump())
128135

129-
job_id = await create_task_history_entry(
130-
user_id=current_user.id,
131-
project_id=project.id,
132-
task_id=results.id,
136+
await update_task_history_entry_with_celery_task_id(
137+
job_id=job_id,
138+
task_id=result.id,
133139
db=db,
134140
)
135141

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ class BcftoolsQueryManager:
127127
VALID_BCFTOOLS_COMMANDS = ["view"] # white-list of valid bcftools commands to run in the pipe.
128128
CONTAINER_NAME = "divbase-worker-quick-1" # for synchronous tasks, use this container name to find the container ID
129129

130-
def execute_pipe(self, command: str, bcftools_inputs: dict, task_id: str = None) -> str:
130+
def execute_pipe(self, command: str, bcftools_inputs: dict, job_id: int) -> str:
131131
"""
132132
Main entrypoint for executing executing divbase queries that require bcftools.
133133
First calls on a method to build a structure of input parameters for bcftools, and then
@@ -144,7 +144,7 @@ def execute_pipe(self, command: str, bcftools_inputs: dict, task_id: str = None)
144144
except BcftoolsEnvironmentError:
145145
raise
146146

147-
identifier = task_id if task_id else datetime.datetime.now().timestamp()
147+
identifier = job_id if job_id else datetime.datetime.now().timestamp()
148148

149149
commands_config_structure = self.build_commands_config(command, bcftools_inputs, identifier)
150150
output_file = self.process_bcftools_commands(commands_config_structure, identifier)
@@ -345,7 +345,7 @@ def merge_or_concat_bcftools_temp_files(self, output_temp_files: list[str], iden
345345
self.temp_files.append(annotated_unsorted_output_file)
346346
self.temp_files.append(divbase_header_for_vcf)
347347

348-
output_file = f"merged_{identifier}.vcf.gz"
348+
output_file = f"result_of_job_{identifier}.vcf.gz"
349349
logger.info("Trying to determine if sample names overlap between temp files...")
350350

351351
sample_names_per_VCF = self._get_all_sample_names_from_vcf_files(output_temp_files)

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,13 @@ def bcftools_pipe_task(
189189
project_id: int,
190190
project_name: str,
191191
user_id: int,
192+
job_id: int,
192193
):
193194
"""
194195
Run a full bcftools query command as a Celery task, with sample metadata filtering run first.
195196
"""
196197
task_id = bcftools_pipe_task.request.id
197-
logger.info(f"Starting bcftools_pipe_task with Celery, task ID: {task_id}")
198+
logger.info(f"Starting bcftools_pipe_task with Celery, task ID: {task_id}, job ID: {job_id}")
198199

199200
s3_file_manager = create_s3_file_manager(url=S3_ENDPOINT_URL)
200201

@@ -258,7 +259,7 @@ def bcftools_pipe_task(
258259

259260
# Let validation exceptions (BcftoolsPipeEmptyCommandError, BcftoolsPipeUnsupportedCommandError,
260261
# SidecarInvalidFilterError) propagate to mark task as FAILURE. Otherwise the tasks will incorrectly be marked as SUCCESS.
261-
output_file = BcftoolsQueryManager().execute_pipe(command, bcftools_inputs, task_id)
262+
output_file = BcftoolsQueryManager().execute_pipe(command, bcftools_inputs, job_id)
262263

263264
_upload_results_file(output_file=Path(output_file), bucket_name=bucket_name, s3_file_manager=s3_file_manager)
264265
_delete_job_files_from_worker(vcf_paths=files_to_download, metadata_path=metadata_path, output_file=output_file)

packages/divbase-lib/src/divbase_lib/api_schemas/queries.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class BcftoolsQueryKwargs(BaseModel):
4545
project_id: int
4646
project_name: str
4747
user_id: int
48+
job_id: int
4849

4950

5051
class SampleMetadataQueryTaskResult(BaseModel):

0 commit comments

Comments
 (0)