Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ REDIS_URL=redis://redis:6379/0
EMBEDDING_JOB_TIMEOUT_SEC=3600
TRAINING_JOB_TIMEOUT_SEC=86400

# Request envelope (MVP production limits)
MAX_SEQUENCES_PER_REQUEST=20 # 20 sequences per request
MAX_SEQUENCE_LENGTH_AA=1000 # 1000 amino acids per sequence
MAX_FASTA_UPLOAD_MB=2 # 2MB
SYNC_PREDICT_TIMEOUT_SEC=600 # 10 minutes
SYNC_PREDICT_POLL_INTERVAL_SEC=1.0 # 1 second

# MinIO (S3-compatible artifact store)
MINIO_ROOT_USER=mlflow-minio
MINIO_ROOT_PASSWORD=change-me-minio
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ services:
REDIS_URL: redis://redis:6379/0
EMBEDDING_JOB_TIMEOUT_SEC: ${EMBEDDING_JOB_TIMEOUT_SEC:-3600}
EMBEDDING_ARTIFACT_ROOT: /app/outputs/service_artifacts
MAX_SEQUENCES_PER_REQUEST: ${MAX_SEQUENCES_PER_REQUEST:-20}
MAX_SEQUENCE_LENGTH_AA: ${MAX_SEQUENCE_LENGTH_AA:-1000}
MAX_FASTA_UPLOAD_MB: ${MAX_FASTA_UPLOAD_MB:-2}
SYNC_PREDICT_TIMEOUT_SEC: ${SYNC_PREDICT_TIMEOUT_SEC:-600}
SYNC_PREDICT_POLL_INTERVAL_SEC: ${SYNC_PREDICT_POLL_INTERVAL_SEC:-1.0}
depends_on:
go-prediction-api:
condition: service_started
Expand Down Expand Up @@ -323,6 +328,10 @@ services:
GATEWAY_USER_PASSWORD: ${GATEWAY_USER_PASSWORD:-change-me-gateway-user}
# Compose uses plain HTTP to nginx; disable TLS verification explicitly.
GATEWAY_VERIFY_TLS: "false"
MAX_SEQUENCES_PER_REQUEST: ${MAX_SEQUENCES_PER_REQUEST:-20}
MAX_SEQUENCE_LENGTH_AA: ${MAX_SEQUENCE_LENGTH_AA:-1000}
MAX_FASTA_UPLOAD_MB: ${MAX_FASTA_UPLOAD_MB:-2}
SYNC_PREDICT_TIMEOUT_SEC: ${SYNC_PREDICT_TIMEOUT_SEC:-600}
depends_on:
- embedding-api
- go-prediction-api
Expand Down
7 changes: 7 additions & 0 deletions examples/small_sequences.fasta
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ RIISSIEQKEENKGGEDKLKMIREYRQMVETELKLICCDILDVLDKHLIPAANTGESKVF
YYKMKGDYHRYLAEFATGNDRKEAAENSLVAYKAASDIAMTELPPTHPIRLGLALNFSVF
YYEILNSPDRACRLAKAAFDDAIAELDTLSEESYKDSTLIMQLLRDNLTLWTSDMQGDGE
EQNKEALQDVEDENQ
>sp|O15162|PLS1_HUMAN Phospholipid scramblase 1 OS=Homo sapiens OX=9606 GN=PLSCR1 PE=1 SV=1
MDKQNSQMNASHPETNLPVGYPPQYPPTAFQGPPGYSGYPGPQVSYPPPPAGHSGPGPAG
FPVPNQPVYNQPVYNQPVGAAGVPWMPAPQPPLNCPPGLEYLSQIDQILIHQQIELLEVL
TGFETNNKYEIKNSFGQRVYFAAEDTDCCTRNCCGPSRPFTLRIIDNMGQEVITLERPLR
CSSCCCPCCLQEIEIQAPPGVPIGYVIQTWHPCLPKFTIQNEKREDVLKISGPCVVCSCC
GDVDFEIKSLDEQCVVGKISKHWTGILREAFTDADNFGIQFPLDLDVKMKAVMIGACFLI
DFMFFESTGSQEQKSGVW
8 changes: 5 additions & 3 deletions nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -128,23 +128,25 @@ http {
auth_basic_user_file /etc/nginx/.htpasswd-user; # auth basic user file for GO prediction API
}

# Training API — regular user + admin (.htpasswd-user lists both)
location /api/v1/predict-go-from-sequences {
client_max_body_size 512m; # client max body size for NGINX
client_max_body_size 2m; # aligned with MAX_FASTA_UPLOAD_MB MVP envelope
limit_req zone=rl_predict burst=80 nodelay; # limit request zone for predict API
set $embedding_api_upstream embedding-api:8000;
proxy_pass http://$embedding_api_upstream; # proxy pass for GO prediction API
proxy_read_timeout 660s;

auth_basic "Prediction API"; # auth basic for GO prediction API
auth_basic_user_file /etc/nginx/.htpasswd-user; # auth basic user file for GO prediction API
}

# FASTA upload → embed → predict GO — regular user + admin (.htpasswd-user lists both)
location /api/v1/predict-go-from-fasta {
client_max_body_size 5m;
client_max_body_size 2m; # aligned with MAX_FASTA_UPLOAD_MB
limit_req zone=rl_predict burst=80 nodelay;
set $embedding_api_upstream embedding-api:8000;
proxy_pass http://$embedding_api_upstream;
# SYNC_PREDICT_TIMEOUT_SEC default 600; keep headroom for cold starts
proxy_read_timeout 660s;

auth_basic "Prediction API";
auth_basic_user_file /etc/nginx/.htpasswd-user;
Expand Down
14 changes: 11 additions & 3 deletions services/embedding-api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@
DEFAULT_BACKEND = "esm2"
DEFAULT_POOLING = "mean"
DEFAULT_BATCH_SIZE = 8
DEFAULT_MAX_LENGTH = 1280

MAX_FASTA_UPLOAD_BYTES = 5 * 1024 * 1024 # 5 MB

GO_PREDICTION_API_URL = os.getenv("GO_PREDICTION_API_URL", "http://go-prediction-api:8000")

Expand All @@ -24,3 +21,14 @@
RQ_RETRY_MAX = int(os.getenv("EMBEDDING_RQ_RETRY_MAX", "3"))
RQ_RETRY_INTERVALS = [10, 60, 180]
WORKER_METRICS_PORT = int(os.getenv("WORKER_METRICS_PORT", "8001"))

# Request envelope (MVP). Tune via .env — do not hardcode in route handlers.
MAX_SEQUENCES_PER_REQUEST = int(os.getenv("MAX_SEQUENCES_PER_REQUEST", "20"))
MAX_SEQUENCE_LENGTH_AA = int(os.getenv("MAX_SEQUENCE_LENGTH_AA", "1000"))
MAX_FASTA_UPLOAD_MB = int(os.getenv("MAX_FASTA_UPLOAD_MB", "2"))
MAX_FASTA_UPLOAD_BYTES = MAX_FASTA_UPLOAD_MB * 1024 * 1024
SYNC_PREDICT_TIMEOUT_SEC = int(os.getenv("SYNC_PREDICT_TIMEOUT_SEC", "600"))
SYNC_PREDICT_POLL_INTERVAL_SEC = float(os.getenv("SYNC_PREDICT_POLL_INTERVAL_SEC", "1.0"))

# Tokenizer window default: match AA length cap unless overridden.
DEFAULT_MAX_LENGTH = int(os.getenv("DEFAULT_MAX_LENGTH", str(MAX_SEQUENCE_LENGTH_AA)))
78 changes: 57 additions & 21 deletions services/embedding-api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@
from config import (
API_PREFIX,
ARTIFACT_ROOT,
DEFAULT_BATCH_SIZE,
DEFAULT_MAX_LENGTH,
GO_PREDICTION_API_URL,
JOBS_DATABASE_URL,
MAX_FASTA_UPLOAD_BYTES,
MAX_FASTA_UPLOAD_MB,
MAX_SEQUENCE_LENGTH_AA,
MAX_SEQUENCES_PER_REQUEST,
SYNC_PREDICT_POLL_INTERVAL_SEC,
SYNC_PREDICT_TIMEOUT_SEC,
)
from job_store import JobStore
from queueing import enqueue_embedding_job, get_queue
Expand Down Expand Up @@ -161,12 +168,33 @@ def metrics() -> Response:
return Response(content=generate_latest(registry), media_type=CONTENT_TYPE_LATEST)


def _enforce_sequence_envelope(sequences: list[str]) -> None:
"""Reject requests that exceed MVP count / AA-length caps from env."""
if len(sequences) > MAX_SEQUENCES_PER_REQUEST:
raise HTTPException(
status_code=400,
detail=(
f"TOO_MANY_SEQUENCES: max {MAX_SEQUENCES_PER_REQUEST}, "
f"got {len(sequences)}"
),
)
for index, sequence in enumerate(sequences):
aa_len = len("".join(sequence.split()))
if aa_len > MAX_SEQUENCE_LENGTH_AA:
raise HTTPException(
status_code=400,
detail=(
f"SEQUENCE_TOO_LONG: index={index} length={aa_len} "
f"max={MAX_SEQUENCE_LENGTH_AA}"
),
)


@app.post(API_PREFIX + "/jobs", response_model=CreateJobResponse, status_code=202)
def create_job(request: CreateJobRequest) -> CreateJobResponse:
_observe_sequence_lengths(
backend=request.backend,
sequences=[seq.sequence for seq in request.sequences],
)
sequences = [seq.sequence for seq in request.sequences]
_enforce_sequence_envelope(sequences)
_observe_sequence_lengths(backend=request.backend, sequences=sequences)
job_id = str(uuid.uuid4())
_create_and_enqueue(job_id, request.model_dump())
return CreateJobResponse(
Expand All @@ -181,16 +209,15 @@ async def create_fasta_job(
fasta_file: UploadFile = File(...),
backend: Literal["esm2", "protbert", "t5"] = Form(default="esm2"),
pooling: Literal["mean", "cls"] = Form(default="mean"),
batch_size: int = Form(default=8),
max_length: int = Form(default=1280),
batch_size: int = Form(default=DEFAULT_BATCH_SIZE),
max_length: int = Form(default=DEFAULT_MAX_LENGTH),
) -> CreateJobResponse:
fasta_text = (await fasta_file.read()).decode("utf-8", errors="replace")
if not fasta_text.strip():
raise HTTPException(status_code=400, detail="Uploaded FASTA is empty.")
fasta_text = await _read_fasta_upload(fasta_file)
try:
_, sequences = parse_fasta_text(fasta_text)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_enforce_sequence_envelope(sequences)
_observe_sequence_lengths(backend=backend, sequences=sequences)

job_id = str(uuid.uuid4())
Expand Down Expand Up @@ -362,6 +389,7 @@ def _parse_and_validate_fasta(fasta_text: str, backend: str) -> None:
status_code=400,
detail=f"FASTA records with empty sequences: {preview}{suffix}",
)
_enforce_sequence_envelope(sequences)
_observe_sequence_lengths(backend=backend, sequences=sequences)


Expand All @@ -379,8 +407,13 @@ def _validate_predict_form_params(
raise HTTPException(status_code=400, detail="max_length must be between 8 and 8192")
if not 1 <= top_k <= 500:
raise HTTPException(status_code=400, detail="top_k must be between 1 and 500")
if not 5 <= timeout_seconds <= 7200:
raise HTTPException(status_code=400, detail="timeout_seconds must be between 5 and 7200")
if not 5 <= timeout_seconds <= SYNC_PREDICT_TIMEOUT_SEC:
raise HTTPException(
status_code=400,
detail=(
f"timeout_seconds must be between 5 and {SYNC_PREDICT_TIMEOUT_SEC}"
),
)
if not 0.1 < poll_interval_seconds <= 5.0:
raise HTTPException(
status_code=400,
Expand All @@ -391,10 +424,9 @@ def _validate_predict_form_params(
async def _read_fasta_upload(fasta_file: UploadFile) -> str:
raw = await fasta_file.read(MAX_FASTA_UPLOAD_BYTES + 1)
if len(raw) > MAX_FASTA_UPLOAD_BYTES:
max_mb = MAX_FASTA_UPLOAD_BYTES // (1024 * 1024)
raise HTTPException(
status_code=413,
detail=f"FASTA_FILE_TOO_LARGE: max {max_mb} MB",
detail=f"FASTA_FILE_TOO_LARGE: max {MAX_FASTA_UPLOAD_MB} MB",
)
fasta_text = raw.decode("utf-8", errors="replace")
if not fasta_text.strip():
Expand Down Expand Up @@ -441,10 +473,14 @@ def _wait_for_job_completion(job_id: str, timeout_seconds: int, poll_interval_se

@app.post(API_PREFIX + "/predict-go-from-sequences", response_model=PredictGoResponse)
def predict_go_from_sequences(request: PredictGoFromSequencesRequest) -> PredictGoResponse:
_observe_sequence_lengths(
backend=request.backend,
sequences=[seq.sequence for seq in request.sequences],
)
sequences = [seq.sequence for seq in request.sequences]
_enforce_sequence_envelope(sequences)
if request.timeout_seconds > SYNC_PREDICT_TIMEOUT_SEC:
raise HTTPException(
status_code=400,
detail=f"timeout_seconds must be <= {SYNC_PREDICT_TIMEOUT_SEC}",
)
_observe_sequence_lengths(backend=request.backend, sequences=sequences)
job_payload = {
"stage": "test",
"backend": request.backend,
Expand All @@ -468,12 +504,12 @@ async def predict_go_from_fasta(
fasta_file: UploadFile = File(...),
backend: Literal["esm2", "protbert", "t5"] = Form(default="esm2"),
pooling: Literal["mean", "cls"] = Form(default="mean"),
batch_size: int = Form(default=8),
max_length: int = Form(default=1280),
batch_size: int = Form(default=DEFAULT_BATCH_SIZE),
max_length: int = Form(default=DEFAULT_MAX_LENGTH),
top_k: int = Form(default=10),
fail_fast: bool = Form(default=True),
timeout_seconds: int = Form(default=1800),
poll_interval_seconds: float = Form(default=1.0),
timeout_seconds: int = Form(default=SYNC_PREDICT_TIMEOUT_SEC),
poll_interval_seconds: float = Form(default=SYNC_PREDICT_POLL_INTERVAL_SEC),
) -> PredictGoResponse:
_validate_predict_form_params(
batch_size=batch_size,
Expand Down
32 changes: 24 additions & 8 deletions services/embedding-api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

from pydantic import BaseModel, Field

from config import (
DEFAULT_BATCH_SIZE,
DEFAULT_MAX_LENGTH,
MAX_SEQUENCES_PER_REQUEST,
SYNC_PREDICT_POLL_INTERVAL_SEC,
SYNC_PREDICT_TIMEOUT_SEC,
)


class SequenceItem(BaseModel):
id: str = Field(min_length=1)
Expand All @@ -14,9 +22,9 @@ class CreateJobRequest(BaseModel):
stage: Literal["test"] = "test"
backend: Literal["esm2", "protbert", "t5"] = "esm2"
pooling: Literal["mean", "cls"] = "mean"
batch_size: int = Field(default=8, ge=1, le=128)
max_length: int = Field(default=1280, ge=8, le=8192)
sequences: list[SequenceItem] = Field(min_length=1)
batch_size: int = Field(default=DEFAULT_BATCH_SIZE, ge=1, le=128)
max_length: int = Field(default=DEFAULT_MAX_LENGTH, ge=8, le=8192)
sequences: list[SequenceItem] = Field(min_length=1, max_length=MAX_SEQUENCES_PER_REQUEST)


class Progress(BaseModel):
Expand Down Expand Up @@ -78,11 +86,19 @@ class PredictGoResponse(BaseModel):
class PredictGoFromSequencesRequest(BaseModel):
backend: Literal["esm2", "protbert", "t5"] = "esm2"
pooling: Literal["mean", "cls"] = "mean"
batch_size: int = Field(default=8, ge=1, le=128)
max_length: int = Field(default=1280, ge=8, le=8192)
sequences: list[SequenceItem] = Field(min_length=1)
batch_size: int = Field(default=DEFAULT_BATCH_SIZE, ge=1, le=128)
max_length: int = Field(default=DEFAULT_MAX_LENGTH, ge=8, le=8192)
sequences: list[SequenceItem] = Field(min_length=1, max_length=MAX_SEQUENCES_PER_REQUEST)
top_k: int = Field(default=10, ge=1, le=500)
indices: list[int] | None = None
fail_fast: bool = True
timeout_seconds: int = Field(default=1800, ge=5, le=7200)
poll_interval_seconds: float = Field(default=1.0, gt=0.1, le=5.0)
timeout_seconds: int = Field(
default=SYNC_PREDICT_TIMEOUT_SEC,
ge=5,
le=SYNC_PREDICT_TIMEOUT_SEC,
)
poll_interval_seconds: float = Field(
default=SYNC_PREDICT_POLL_INTERVAL_SEC,
gt=0.1,
le=5.0,
)
4 changes: 2 additions & 2 deletions services/streamlit-ui/.streamlit/config.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[server]
# Align uploader caption + hard limit with API/nginx FASTA cap (5 MB).
maxUploadSize = 5
# Align uploader hard limit with MAX_FASTA_UPLOAD_MB (env / API / nginx).
maxUploadSize = 2
26 changes: 15 additions & 11 deletions services/streamlit-ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
from requests.exceptions import RequestException

from validation import (
MAX_FASTA_UPLOAD_BYTES,
MAX_FASTA_UPLOAD_MB,
MAX_SEQUENCE_LENGTH_AA,
MAX_SEQUENCES_PER_REQUEST,
SYNC_PREDICT_TIMEOUT_SEC,
GatewayConfig,
load_gateway_config,
normalize_sequence,
Expand All @@ -32,8 +35,6 @@
PREDICT_SEQUENCES_ENDPOINT = "/api/v1/predict-go-from-sequences"
PREDICT_FASTA_ENDPOINT = "/api/v1/predict-go-from-fasta"
MAX_TOP_K = 500
SEQUENCE_TIMEOUT_SECONDS = 600
FASTA_TIMEOUT_SECONDS = 1800
PREDICTION_MODE_SEQUENCE = "Prediction with sequence"
PREDICTION_MODE_FASTA = "Prediction with FASTA"

Expand All @@ -55,8 +56,9 @@ def build_request_payload(sequence: str, top_k: int) -> dict[str, Any]:
"backend": "esm2",
"pooling": "mean",
"batch_size": 1,
"max_length": 1280,
"max_length": MAX_SEQUENCE_LENGTH_AA,
"top_k": top_k,
"timeout_seconds": SYNC_PREDICT_TIMEOUT_SEC,
"sequences": [{"id": "input_1", "sequence": sequence}],
}

Expand Down Expand Up @@ -103,7 +105,7 @@ def call_fasta_prediction_api(
"backend": "esm2",
"pooling": "mean",
"batch_size": "8",
"max_length": "1280",
"max_length": str(MAX_SEQUENCE_LENGTH_AA),
"top_k": str(top_k),
"fail_fast": "true",
"timeout_seconds": str(timeout_seconds),
Expand Down Expand Up @@ -231,16 +233,17 @@ def main() -> None:
gateway=gateway,
sequence=cleaned,
top_k=int(top_k),
timeout_seconds=SEQUENCE_TIMEOUT_SECONDS,
timeout_seconds=SYNC_PREDICT_TIMEOUT_SEC,
)
else:
with st.form("predict_fasta_form"):
fasta_file = st.file_uploader(
"Protein FASTA file",
type=["fasta", "fa", "txt"],
help=(
f"Upload a UTF-8 FASTA file (max {MAX_FASTA_UPLOAD_BYTES // (1024 * 1024)} MB). "
"All records in the file are embedded and predicted. "
f"Upload a UTF-8 FASTA file (max {MAX_FASTA_UPLOAD_MB} MB, "
f"up to {MAX_SEQUENCES_PER_REQUEST} sequences, "
f"each <= {MAX_SEQUENCE_LENGTH_AA} aa). "
"Non-canonical residues are normalized by the API at embedding time."
),
)
Expand All @@ -265,8 +268,9 @@ def main() -> None:
)
if record_count > 1:
st.info(
f"This FASTA contains {record_count} sequences. "
"Large files may take several minutes to complete."
f"This FASTA contains {record_count} sequences "
f"(limit {MAX_SEQUENCES_PER_REQUEST}). "
"Larger files may take several minutes to complete."
)

with st.spinner(f"Submitting request to {PREDICT_FASTA_ENDPOINT} ..."):
Expand All @@ -275,7 +279,7 @@ def main() -> None:
file_bytes=file_bytes,
filename=fasta_file.name,
top_k=int(top_k),
timeout_seconds=FASTA_TIMEOUT_SECONDS,
timeout_seconds=SYNC_PREDICT_TIMEOUT_SEC,
)

if not ok:
Expand Down
Loading
Loading