Skip to content

Commit 111f27f

Browse files
committed
Add invoke api method to template
1 parent eeac7fe commit 111f27f

4 files changed

Lines changed: 143 additions & 7 deletions

File tree

app/grandchallenge/forge/templates/forge/partials/example_algorithm/Dockerfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ RUN python -m pip install \
1818
--no-color \
1919
--requirement /opt/app/requirements.txt
2020

21+
COPY --chown=user:user app.py /opt/app/
2122
COPY --chown=user:user inference.py /opt/app/
2223

23-
ENTRYPOINT ["python", "inference.py"]
24+
LABEL org.grand-challenge.api-method="invoke"
25+
26+
ENTRYPOINT ["python", "app.py"]

app/grandchallenge/forge/templates/forge/partials/example_algorithm/app.py.template

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,17 @@ Any implementation will do as long as it:
99
3. On the invoke endpoint invokes the algorithm for inference and returns HTTP 201 CREATED
1010

1111
"""
12+
from contextlib import asynccontextmanager
1213
from pathlib import Path
1314

15+
from fastapi import FastAPI, Response, status
1416
import torch
17+
import uvicorn
18+
19+
import inference
20+
21+
22+
from uvicorn.config import LOGGING_CONFIG
1523

1624

1725
def _show_torch_cuda_info():
@@ -45,3 +53,56 @@ def init_model():
4553
print(f.read())
4654

4755
return model
56+
57+
58+
MODELS = {}
59+
60+
61+
# During the lifespan of your inference server, your model should be ready
62+
# for invocations.It is important to load your model here, and not just
63+
# before running inference, to allow the inference time to be as short as
64+
# possible. Each invocation will have a timeout, so if your model still
65+
# needs to be loaded when the /invoke endpoint is called, there may not be
66+
# enough time for processing.
67+
@asynccontextmanager
68+
async def lifespan(app: FastAPI):
69+
# Load the ML model
70+
MODELS["answer_to_everything"] = init_model()
71+
yield
72+
# Clean up the models and release the resources
73+
MODELS.clear()
74+
75+
76+
app = FastAPI(lifespan=lifespan)
77+
78+
79+
# After starting your inference server, the health endpoint will
80+
# be called repeatedly until it returns a 200 response.
81+
# Redirect responses will not be followed and will raise an exception.
82+
# Any other response will be ignored.
83+
@app.get("/health")
84+
async def health():
85+
try:
86+
# check if the model is initialized
87+
_ = MODELS["answer_to_everything"]
88+
return Response(status_code=status.HTTP_200_OK)
89+
except KeyError:
90+
return Response(status_code=status.HTTP_404_NOT_FOUND)
91+
92+
93+
# After the health endpoint returns a 200 response,
94+
# the invoke endpoint will be called (one or more times)
95+
# to invoke inference on the inputs in the input folder.
96+
# When inference is done, this endpoint should return a 201 response.
97+
# Any other response will raise an exception and fail.
98+
@app.post("/invoke")
99+
async def invoke():
100+
model = MODELS["answer_to_everything"]
101+
inference.run(model)
102+
return Response(status_code=status.HTTP_201_CREATED)
103+
104+
105+
if __name__ == "__main__":
106+
log_config = LOGGING_CONFIG.copy()
107+
log_config["handlers"]["default"]["stream"] = "ext://sys.stdout"
108+
uvicorn.run(app, host="0.0.0.0", port=4743, log_config=log_config)

app/grandchallenge/forge/templates/forge/partials/example_algorithm/do_test_run.sh.template

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ set -e
55

66
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
77
DOCKER_IMAGE_TAG="example_algorithm_{{ object.slug }}"
8+
CONTAINER_NAME="example_algorithm_{{ object.slug }}_container"
9+
PORT=37847
810

911
DOCKER_NOOP_VOLUME="${DOCKER_IMAGE_TAG}-volume"
1012

@@ -26,6 +28,9 @@ cleanup() {
2628
$DOCKER_IMAGE_TAG \
2729
-c "chmod -R -f o+rwX /output/* || true"
2830

31+
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
32+
echo "=+= Container stopped"
33+
2934
# Ensure volume is removed
3035
docker volume rm "$DOCKER_NOOP_VOLUME" > /dev/null
3136
}
@@ -56,12 +61,14 @@ docker volume create "$DOCKER_NOOP_VOLUME" > /dev/null
5661

5762
trap cleanup EXIT
5863

59-
run_docker_forward_pass() {
64+
start_docker_container() {
6065
local interface_dir="$1"
6166

62-
echo "=+= Doing a forward pass on ${interface_dir}"
67+
echo "=+= Starting container"
6368

6469
## Note the extra arguments that are passed here:
70+
# '-p ${PORT}:4743'
71+
# maps local port to container port 4743
6572
# '--network none'
6673
# entails there is no internet connection
6774
# '--gpus all'
@@ -70,20 +77,83 @@ run_docker_forward_pass() {
7077
# is added because on Grand Challenge this directory cannot be used to store permanent files
7178
# '--volume ../model:/opt/ml/model/":ro'
7279
# is added to provide access to the (optional) tarball-upload locally
73-
docker run --rm {% if not no_gpus %}--gpus all {% endif %}\
80+
docker run -d {% if not no_gpus %}--gpus all {% endif %}\
81+
--name "$CONTAINER_NAME" \
7482
--platform=linux/amd64 \
75-
--network none \
83+
-p ${PORT}:4743 \
7684
--volume "${INPUT_DIR}/${interface_dir}":/input:ro \
7785
--volume "${OUTPUT_DIR}/${interface_dir}":/output \
7886
--volume "$DOCKER_NOOP_VOLUME":/tmp \
7987
--volume "${SCRIPT_DIR}/model":/opt/ml/model:ro \
80-
"$DOCKER_IMAGE_TAG"
88+
"$DOCKER_IMAGE_TAG" \
89+
>/dev/null
90+
91+
echo "=+= Container started"
92+
}
93+
94+
check_health() {
95+
echo "=+= Waiting for health endpoint..."
96+
97+
local max_attempts=30
98+
local delay=10
99+
100+
for ((i=1;i<=max_attempts;i++)); do
101+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
102+
--max-time 10 \
103+
http://localhost:${PORT}/health || echo "000")
104+
105+
echo "Health check attempt $i/$max_attempts returned $STATUS"
106+
107+
if [[ "$STATUS" == "200" ]]; then
108+
echo "=+= API healthy"
109+
return 0
110+
fi
111+
112+
if [[ "$STATUS" == "302" ]]; then
113+
echo "Health endpoint returned 302 — failing"
114+
return 1
115+
fi
116+
117+
echo "Retrying in ${delay}s"
118+
sleep "$delay"
119+
done
120+
121+
echo "Health endpoint never returned 200"
122+
return 1
123+
}
124+
125+
run_docker_forward_pass() {
126+
local interface_dir="$1"
127+
128+
echo "=+= Doing a forward pass on ${interface_dir}"
129+
130+
echo "=+= Calling invoke endpoint"
131+
132+
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
133+
--max-time 300 \
134+
-X POST http://localhost:${PORT}/invoke || echo "000")
135+
136+
if [ "$STATUS" != "201" ]; then
137+
echo "Invoke failed with status $STATUS"
138+
exit 1
139+
fi
140+
141+
echo "=+= Invoke completed"
142+
143+
echo "=+= Wrote results to ${OUTPUT_DIR}/${interface_dir}"
144+
}
81145

82-
echo "=+= Wrote results to ${OUTPUT_DIR}/${interface_dir}"
146+
stop_docker_container() {
147+
echo "=+= Stopping container"
148+
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
149+
echo "=+= Container stopped"
83150
}
84151

85152
{% for interface_name in object.algorithm_interface_names %}
153+
start_docker_container "{{ interface_name }}"
154+
check_health
86155
run_docker_forward_pass "{{ interface_name }}"
156+
stop_docker_container
87157
{% endfor %}
88158

89159

app/grandchallenge/forge/templates/forge/partials/example_algorithm/requirements.txt.template

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ pylibjpeg
1212
pylibjpeg-libjpeg
1313
pylibjpeg-openjpeg
1414
{% endif %}
15+
fastapi
16+
uvicorn

0 commit comments

Comments
 (0)