Skip to content

Commit 70cfa38

Browse files
authored
Allow setting the algorithm api method (#4469)
Adds the ability to set the API method by adding a label on the container (`org.grand-challenge.api-method`). The value of this can be `exec` (default, set if label is missing) or `invoke` (new). The api method is then passed through to the instance at runtime as `GRAND_CHALLENGE_COMPONENT_API_METHOD` for pickup by SageMaker Shim. See DIAGNijmegen/rse-roadmap#400
1 parent 4616c7e commit 70cfa38

14 files changed

Lines changed: 324 additions & 3 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Generated by Django 5.2.10 on 2026-01-27 13:09
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("algorithms", "0089_job_algorithms__created_679c29_idx"),
11+
(
12+
"uploads",
13+
"0009_useruploadgroupobjectpermission_uploads_use_group_i_53998e_idx",
14+
),
15+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16+
]
17+
18+
operations = [
19+
migrations.AddField(
20+
model_name="algorithmimage",
21+
name="api_method",
22+
field=models.CharField(
23+
choices=[("exec", "Exec"), ("invoke", "Invoke")],
24+
default="exec",
25+
editable=False,
26+
max_length=6,
27+
),
28+
),
29+
migrations.AddConstraint(
30+
model_name="algorithmimage",
31+
constraint=models.CheckConstraint(
32+
condition=models.Q(("api_method__in", ["exec", "invoke"])),
33+
name="algorithms_algorithmimage_api_method_in_choices",
34+
),
35+
),
36+
]

app/grandchallenge/challenges/migrations/0001_initial.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Migration(migrations.Migration):
2222
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
2323
("task_categories", "0001_initial"),
2424
("publications", "0003_auto_20201001_0758"),
25+
("modalities", "0001_initial"),
2526
]
2627

2728
operations = [

app/grandchallenge/challenges/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
send_email_percent_budget_consumed_alert,
4848
)
4949
from grandchallenge.challenges.utils import ChallengeTypeChoices
50+
from grandchallenge.components.models import APIMethodChoices
5051
from grandchallenge.components.schemas import (
5152
SELECTABLE_GPU_TYPES_SCHEMA,
5253
GPUTypeChoices,
@@ -1323,6 +1324,7 @@ def compute_costs_euro_cents_per_hour(self):
13231324
requires_gpu_type=gpu_type,
13241325
use_warm_pool=False,
13251326
signing_key=b"",
1327+
api_method=APIMethodChoices.EXEC,
13261328
)
13271329
for gpu_type in self.algorithm_selectable_gpu_type_choices
13281330
]

app/grandchallenge/components/backends/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
)
4242
from grandchallenge.components.backends.utils import user_error
4343
from grandchallenge.components.models import (
44+
APIMethodChoices,
4445
ComponentInterface,
4546
ComponentInterfaceValue,
4647
InterfaceKindChoices,
@@ -320,6 +321,7 @@ def __init__(
320321
requires_gpu_type: GPUTypeChoices,
321322
use_warm_pool: bool,
322323
signing_key: bytes,
324+
api_method: APIMethodChoices,
323325
algorithm_model=None,
324326
ground_truth=None,
325327
**kwargs,
@@ -334,6 +336,7 @@ def __init__(
334336
use_warm_pool and settings.COMPONENTS_USE_WARM_POOL
335337
)
336338
self._signing_key = signing_key
339+
self._api_method = api_method
337340
self._algorithm_model = algorithm_model
338341
self._ground_truth = ground_truth
339342

@@ -450,6 +453,7 @@ def invocation_environment(self):
450453
"GRAND_CHALLENGE_COMPONENT_SIGNING_KEY_HEX": binascii.hexlify(
451454
self._signing_key
452455
).decode("ascii"),
456+
"GRAND_CHALLENGE_COMPONENT_API_METHOD": self._api_method,
453457
}
454458

455459
if self._algorithm_model:

app/grandchallenge/components/models.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
RegexValidator,
2525
)
2626
from django.db import models, transaction
27-
from django.db.models import IntegerChoices, QuerySet
27+
from django.db.models import IntegerChoices, QuerySet, TextChoices
2828
from django.db.transaction import on_commit
2929
from django.forms import ModelChoiceField
3030
from django.template.defaultfilters import truncatewords
@@ -1851,6 +1851,7 @@ def executor_kwargs(self):
18511851
"memory_limit": self.requires_memory_gb,
18521852
"use_warm_pool": self.use_warm_pool,
18531853
"signing_key": self.signing_key,
1854+
"api_method": self.container.api_method,
18541855
}
18551856

18561857
def get_executor(self, *, backend):
@@ -2028,6 +2029,11 @@ class ImportStatusChoices(IntegerChoices):
20282029
COMPLETED = 6, "Completed"
20292030

20302031

2032+
class APIMethodChoices(TextChoices):
2033+
EXEC = "exec", "Exec"
2034+
INVOKE = "invoke", "Invoke"
2035+
2036+
20312037
class ComponentImageManager(models.Manager):
20322038
def executable_images(self):
20332039
return self.filter(
@@ -2068,6 +2074,12 @@ class ComponentImage(FieldChangeMixin, models.Model):
20682074
storage=private_s3_storage,
20692075
max_length=255,
20702076
)
2077+
api_method = models.CharField(
2078+
editable=False,
2079+
max_length=6,
2080+
choices=APIMethodChoices,
2081+
default=APIMethodChoices.EXEC,
2082+
)
20712083
image_sha256 = models.CharField(editable=False, max_length=71)
20722084
latest_shimmed_version = models.CharField(
20732085
editable=False, max_length=8, default=""
@@ -2262,6 +2274,12 @@ def shimmed_repo_tag(self):
22622274

22632275
class Meta:
22642276
abstract = True
2277+
constraints = (
2278+
models.CheckConstraint(
2279+
condition=models.Q(api_method__in=APIMethodChoices.values),
2280+
name="%(app_label)s_%(class)s_api_method_in_choices",
2281+
),
2282+
)
22652283

22662284
@property
22672285
def animate(self):

app/grandchallenge/components/tasks.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,8 @@ def _validate_docker_image_manifest(*, instance) -> str:
627627
f"{desired_arch!r}."
628628
)
629629

630+
instance.api_method = _get_image_api_method(config=config)
631+
630632
if instance._meta.model_name != "method":
631633
# TODO Methods are currently allowed to be duplicated
632634
model = apps.get_model(
@@ -736,6 +738,29 @@ def _get_image_config_file(
736738
return {"image_sha256": image_sha256, "config": config}
737739

738740

741+
def _get_image_api_method(*, config):
742+
from grandchallenge.components.models import APIMethodChoices
743+
744+
label = "org.grand-challenge.api-method"
745+
allowed_values = APIMethodChoices.values
746+
747+
labels = config["config"].get("Labels") or {}
748+
749+
for key, value in labels.items():
750+
if str(key).lower().strip() == label:
751+
cleaned_value = (
752+
str(value).lower().replace("'", "").replace('"', "").strip()
753+
)
754+
if cleaned_value in allowed_values:
755+
return cleaned_value
756+
else:
757+
raise ValidationError(
758+
f"The label {label} must be one of {allowed_values}, instead we found '{value}'."
759+
)
760+
else:
761+
return APIMethodChoices.EXEC
762+
763+
739764
def lock_for_utilization_update(*, algorithm_image_pk):
740765
from grandchallenge.algorithms.models import AlgorithmImage
741766

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 5.2.9 on 2025-12-09 15:03
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
(
11+
"evaluation",
12+
"0103_alter_evaluationgroundtruth_ground_truth_and_more",
13+
),
14+
(
15+
"uploads",
16+
"0009_useruploadgroupobjectpermission_uploads_use_group_i_53998e_idx",
17+
),
18+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
19+
]
20+
21+
operations = [
22+
migrations.AddField(
23+
model_name="method",
24+
name="api_method",
25+
field=models.CharField(
26+
choices=[("exec", "Exec"), ("invoke", "Invoke")],
27+
default="exec",
28+
editable=False,
29+
max_length=6,
30+
),
31+
),
32+
migrations.AddConstraint(
33+
model_name="method",
34+
constraint=models.CheckConstraint(
35+
condition=models.Q(("api_method__in", ["exec", "invoke"])),
36+
name="evaluation_method_api_method_in_choices",
37+
),
38+
),
39+
]

app/grandchallenge/evaluation/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,6 +1440,9 @@ class Method(UUIDModel, ComponentImage):
14401440

14411441
phase = models.ForeignKey(Phase, on_delete=models.PROTECT, null=True)
14421442

1443+
class Meta(UUIDModel.Meta, ComponentImage.Meta):
1444+
pass
1445+
14431446
def save(self, *args, **kwargs):
14441447
adding = self._state.adding
14451448

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Generated by Django 5.2.9 on 2025-12-09 15:02
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
(
11+
"uploads",
12+
"0009_useruploadgroupobjectpermission_uploads_use_group_i_53998e_idx",
13+
),
14+
("workstations", "0033_alter_workstation_logo"),
15+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16+
]
17+
18+
operations = [
19+
migrations.AddField(
20+
model_name="workstationimage",
21+
name="api_method",
22+
field=models.CharField(
23+
choices=[("exec", "Exec"), ("invoke", "Invoke")],
24+
default="exec",
25+
editable=False,
26+
max_length=6,
27+
),
28+
),
29+
migrations.AddConstraint(
30+
model_name="workstationimage",
31+
constraint=models.CheckConstraint(
32+
condition=models.Q(("api_method__in", ["exec", "invoke"])),
33+
name="workstations_workstationimage_api_method_in_choices",
34+
),
35+
),
36+
]

app/tests/components_tests/test_amazon_sagemaker_training_backend.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ComponentException,
2121
TaskCancelled,
2222
)
23+
from grandchallenge.components.models import APIMethodChoices
2324
from grandchallenge.components.schemas import GPUTypeChoices
2425
from grandchallenge.evaluation.models import Evaluation, Method
2526

@@ -52,6 +53,7 @@ def test_instance_type(memory_limit, expected_type, requires_gpu_type):
5253
requires_gpu_type=requires_gpu_type,
5354
use_warm_pool=False,
5455
signing_key=b"",
56+
api_method=APIMethodChoices.EXEC,
5557
)
5658

5759
assert executor._instance_type.name == expected_type
@@ -77,6 +79,7 @@ def test_instance_type_incompatible(memory_limit, requires_gpu_type):
7779
requires_gpu_type=requires_gpu_type,
7880
use_warm_pool=False,
7981
signing_key=b"",
82+
api_method=APIMethodChoices.EXEC,
8083
)
8184

8285
with pytest.raises(ValueError):
@@ -115,6 +118,7 @@ def test_invocation_prefix():
115118
requires_gpu_type=GPUTypeChoices.NO_GPU,
116119
use_warm_pool=False,
117120
signing_key=b"",
121+
api_method=APIMethodChoices.EXEC,
118122
)
119123

120124
# The id of the job must be in the prefixes
@@ -173,6 +177,7 @@ def test_invocation_json(settings):
173177
requires_gpu_type=GPUTypeChoices.NO_GPU,
174178
use_warm_pool=False,
175179
signing_key=b"totallysecret",
180+
api_method=APIMethodChoices.EXEC,
176181
)
177182

178183
with Stubber(executor._sagemaker_client) as s:
@@ -207,6 +212,7 @@ def test_invocation_json(settings):
207212
"no_proxy": "amazonaws.com",
208213
"GRAND_CHALLENGE_COMPONENT_MAX_MEMORY_MB": "7168",
209214
"GRAND_CHALLENGE_COMPONENT_SIGNING_KEY_HEX": "746f74616c6c79736563726574",
215+
"GRAND_CHALLENGE_COMPONENT_API_METHOD": "exec",
210216
},
211217
"VpcConfig": {
212218
"SecurityGroupIds": [
@@ -256,6 +262,7 @@ def test_set_duration():
256262
requires_gpu_type=GPUTypeChoices.NO_GPU,
257263
use_warm_pool=False,
258264
signing_key=b"",
265+
api_method=APIMethodChoices.EXEC,
259266
)
260267

261268
assert executor.utilization_duration is None
@@ -283,6 +290,7 @@ def test_get_log_stream_name(settings):
283290
requires_gpu_type=GPUTypeChoices.NO_GPU,
284291
use_warm_pool=False,
285292
signing_key=b"",
293+
api_method=APIMethodChoices.EXEC,
286294
)
287295

288296
with Stubber(executor._logs_client) as s:
@@ -315,6 +323,7 @@ def test_set_task_logs(settings):
315323
requires_gpu_type=GPUTypeChoices.NO_GPU,
316324
use_warm_pool=False,
317325
signing_key=b"",
326+
api_method=APIMethodChoices.EXEC,
318327
)
319328

320329
assert executor.stdout == ""
@@ -462,6 +471,7 @@ def test_set_runtime_metrics(settings):
462471
requires_gpu_type=GPUTypeChoices.NO_GPU,
463472
use_warm_pool=False,
464473
signing_key=b"",
474+
api_method=APIMethodChoices.EXEC,
465475
)
466476

467477
assert executor.runtime_metrics == {}
@@ -558,6 +568,7 @@ def test_handle_completed_job():
558568
requires_gpu_type=GPUTypeChoices.NO_GPU,
559569
use_warm_pool=False,
560570
signing_key=b"itsasecret",
571+
api_method=APIMethodChoices.EXEC,
561572
)
562573

563574
inference_result = InferenceResult(
@@ -604,6 +615,7 @@ def test_handle_time_limit_exceded(settings):
604615
requires_gpu_type=GPUTypeChoices.NO_GPU,
605616
use_warm_pool=False,
606617
signing_key=b"",
618+
api_method=APIMethodChoices.EXEC,
607619
)
608620

609621
with pytest.raises(ComponentException) as error:
@@ -629,6 +641,7 @@ def test_handle_stopped_event(settings):
629641
requires_gpu_type=GPUTypeChoices.NO_GPU,
630642
use_warm_pool=False,
631643
signing_key=b"",
644+
api_method=APIMethodChoices.EXEC,
632645
)
633646

634647
with (
@@ -737,6 +750,7 @@ def test_deprovision(settings):
737750
requires_gpu_type=GPUTypeChoices.NO_GPU,
738751
use_warm_pool=False,
739752
signing_key=b"",
753+
api_method=APIMethodChoices.EXEC,
740754
)
741755

742756
created_files = (

0 commit comments

Comments
 (0)