Skip to content
Draft
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
42bfa7e
Move compute_balance calcuation to python
chrisvanrun May 12, 2026
55df239
Refactor computation for readability
chrisvanrun May 12, 2026
da01369
Add test for multiple challenges
chrisvanrun May 12, 2026
e1cff9f
Return invoices
chrisvanrun May 12, 2026
dbdf0b9
Push invoice compute balance to model
chrisvanrun May 12, 2026
481410b
Move compute_balance calcuation to python
chrisvanrun May 12, 2026
a3e05c2
Refactor computation for readability
chrisvanrun May 12, 2026
e30357f
Add test for multiple challenges
chrisvanrun May 12, 2026
4fd8c03
Add FK from utilizations to (Chalenge) invoices
chrisvanrun May 8, 2026
a1f804c
Add utils invoice to the admin views
chrisvanrun May 8, 2026
00f2518
Add management command for linking utilizations
chrisvanrun May 8, 2026
52a9119
WIP available compute
chrisvanrun May 11, 2026
eb4fcc8
Fix get_invoice_for_utilization
chrisvanrun May 11, 2026
67b7e65
Add utilization computation
chrisvanrun May 11, 2026
ea2ee18
Re-introduce general challenge-level costs cache
chrisvanrun May 11, 2026
aeded35
Add challenge-level with compute_balance
chrisvanrun May 12, 2026
8a0e697
Add invoice argument to create_evaluation
chrisvanrun May 12, 2026
e9cdee8
Add invoice handling to reevaluate_submissions
chrisvanrun May 12, 2026
28dbd6c
Refactor get_invoice_for_utilization
chrisvanrun May 12, 2026
46bc8e5
Handle ripple from refactor
chrisvanrun May 12, 2026
717ed43
Add invoice handling to reevaluate view
chrisvanrun May 12, 2026
616630d
Random refactor
chrisvanrun May 12, 2026
86a7c3b
Fix rebase misses
chrisvanrun May 13, 2026
3dc5607
Missed rebase change
chrisvanrun May 13, 2026
2d716c0
Refactor
chrisvanrun May 13, 2026
100bcb6
Change when utilization is created
chrisvanrun May 13, 2026
26dbb58
Rename method into active_invoice
chrisvanrun May 15, 2026
b05492a
Add test for SubmissionForm invoice usage
chrisvanrun May 15, 2026
d702e8a
Make active invoice uncached
chrisvanrun May 15, 2026
7a12dbf
Assign active invoice to evaluation related jobs
chrisvanrun May 15, 2026
0dcbab3
Rebase cleanup
chrisvanrun May 15, 2026
0db089e
Remove no longer used queryset method
chrisvanrun May 15, 2026
34af345
Add overall balance check test
chrisvanrun May 15, 2026
036ecd2
Refactor the compute annotation
chrisvanrun May 15, 2026
a9935ae
Minor factory refactor
chrisvanrun May 15, 2026
dab2b7f
Fix tests, add a bit of spit and polish
chrisvanrun May 15, 2026
3149caf
Fix hosts of subtle test breakages
chrisvanrun May 15, 2026
378895e
Minor naming
chrisvanrun May 15, 2026
1ffcf56
Remove defensive catch
chrisvanrun May 18, 2026
ada1dfa
Make error message clear so admins contact support
chrisvanrun May 19, 2026
738f5e1
Remove arguments and invoice creations
chrisvanrun May 19, 2026
aae7895
Revert manager creation adding of invoices
chrisvanrun May 19, 2026
ea9c4f9
Remove kwargs only
chrisvanrun May 19, 2026
77601c6
Correctly calculate utlization
chrisvanrun May 19, 2026
10a52df
Remove cached property, for now
chrisvanrun May 19, 2026
f3c78fd
Fix check on error message
chrisvanrun May 19, 2026
734e091
Remove cached-property tests (for now)
chrisvanrun May 21, 2026
2e74309
Rewrite command
chrisvanrun May 21, 2026
5b63538
Add counter
chrisvanrun May 21, 2026
1fd0a1f
Minor improve reporting on progress
chrisvanrun May 21, 2026
b2aa2e9
Report updated rows.
chrisvanrun May 22, 2026
6d85474
Minor reporting update
chrisvanrun May 22, 2026
bec29cf
Merge branch 'main' into link-utilization-and-challenge-invoices
chrisvanrun May 22, 2026
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
4 changes: 2 additions & 2 deletions app/grandchallenge/algorithms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1439,8 +1439,8 @@ def get_or_create_display_set(self, *, reader_study):

return display_set

def create_utilization(self):
JobUtilization.objects.create(job=self)
def create_utilization(self, *, invoice):
JobUtilization.objects.create(job=self, invoice=invoice)
Comment thread
chrisvanrun marked this conversation as resolved.
Outdated

@property
def utilization(self):
Expand Down
18 changes: 16 additions & 2 deletions app/grandchallenge/algorithms/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from django.utils import timezone

from grandchallenge.algorithms.exceptions import TooManyJobsScheduled
from grandchallenge.challenges.exceptions import InsufficientBudgetError
from grandchallenge.components.schemas import GPUTypeChoices
from grandchallenge.components.tasks import (
remove_container_image_from_registry,
Expand Down Expand Up @@ -164,6 +165,21 @@ def create_algorithm_jobs(
input_civ_set=ai.values.all(),
use_warm_pool=use_warm_pool,
)
jobs.append(
job
) # Keep track of created jobs, even if utilization setup has failed
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
) # Keep track of created jobs, even if utilization setup has failed
)

The comment might be confusing. I started rewriting it until I realized this comment is not really necessary at all, the code explains itself.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code expects that the returned jobs are the ones that have been scheduled, so the utilisation set up must not fail.


if job_utilization_challenge is not None:
Comment thread
chrisvanrun marked this conversation as resolved.
try:
job.utilization.invoice = (
job_utilization_challenge.active_invoice
)
except InsufficientBudgetError:
job.update_status(
status=Job.FAILURE,
error_message="Job cannot be executed. The challenge has insufficient budget to run this job.",
)
break
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With James' comment here, this will need to be changed. The invoice will need to be passed along as a kwarg to this function.


job.utilization.archive = ai.archive
job.utilization.phase = job_utilization_phase
Expand All @@ -172,8 +188,6 @@ def create_algorithm_jobs(

job.execute()

jobs.append(job)

return jobs


Expand Down
35 changes: 18 additions & 17 deletions app/grandchallenge/challenges/costs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.contrib.auth.models import Permission
from django.db.models import Sum
from django.db.models import Sum, Value
from django.db.models.functions import Coalesce

from grandchallenge.algorithms.models import (
AlgorithmImage,
Expand All @@ -13,30 +14,30 @@
EvaluationGroundTruth,
Method,
)
from grandchallenge.invoices.models import Invoice
from grandchallenge.utilization.models import (
EvaluationUtilization,
JobUtilization,
JobWarmPoolUtilization,
)


def annotate_compute_costs(*, challenge):
algorithm_job_utilizations = JobUtilization.objects.filter(
challenge=challenge
)
job_warm_pool_utilizations = JobWarmPoolUtilization.objects.filter(
challenge=challenge
)
evaluation_job_utilizations = EvaluationUtilization.objects.filter(
challenge=challenge
)

update_compute_cost_euro_millicents(
obj=challenge,
algorithm_job_utilizations=algorithm_job_utilizations,
job_warm_pool_utilizations=job_warm_pool_utilizations,
evaluation_job_utilizations=evaluation_job_utilizations,
def annotate_compute_costs(*, invoice):
result = Invoice.objects.filter(pk=invoice.pk).aggregate(
Comment thread
chrisvanrun marked this conversation as resolved.
Outdated
total=Coalesce(
Sum("job_utilizations__compute_cost_euro_millicents"),
Value(0),
)
+ Coalesce(
Sum("job_warm_pool_utilizations__compute_cost_euro_millicents"),
Value(0),
)
+ Coalesce(
Sum("evaluation_utilizations__compute_cost_euro_millicents"),
Value(0),
)
)
invoice.compute_costs_utilized_euros_millicents = result["total"]


def annotate_job_duration_and_compute_costs(*, phase):
Expand Down
2 changes: 2 additions & 0 deletions app/grandchallenge/challenges/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class InsufficientBudgetError(Exception):
pass
32 changes: 32 additions & 0 deletions app/grandchallenge/challenges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
send_challenge_requested_email_to_reviewers,
send_email_percent_budget_consumed_alert,
)
from grandchallenge.challenges.exceptions import InsufficientBudgetError
from grandchallenge.challenges.utils import ChallengeTypeChoices
from grandchallenge.components.models import APIMethodChoices
from grandchallenge.components.schemas import GPUTypeChoices
Expand Down Expand Up @@ -937,6 +938,37 @@ def has_paid_prepaid_invoice(self):
payment_status=PaymentStatusChoices.PAID,
).exists()

@cached_property
def compute_costs_balance_euros_millicents(self):
return self._get_compute_costs_balance_euros_millicents(
invoices=self.invoices.all()
)

def _get_compute_costs_balance_euros_millicents(self, *, invoices):
return sum(
invoice.compute_costs_balance_euros_millicents
for invoice in invoices
)
Comment thread
amickan marked this conversation as resolved.
Outdated

@property
def active_invoice(self):
invoices = self.invoices.order_by("expires_on", "created")

if (
Comment thread
chrisvanrun marked this conversation as resolved.
Outdated
self._get_compute_costs_balance_euros_millicents(invoices=invoices)
<= 0
):
raise InsufficientBudgetError

for invoice in invoices:
if invoice.compute_costs_balance_euros_millicents > 0:
return invoice

# Defensive catch
raise ValueError(
"Unexpected: this challenge has no available budget in any invoice despite a positive total balance."
)
Comment thread
chrisvanrun marked this conversation as resolved.
Outdated


class ChallengeUserObjectPermission(UserObjectPermissionBase):
allowed_permissions = frozenset()
Expand Down
40 changes: 29 additions & 11 deletions app/grandchallenge/challenges/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Count, Max, Min, Q
from django.db.models import Count, Max, Min, Q, Sum
from django.utils.timezone import datetime, now
from psycopg.errors import LockNotAvailable

Expand All @@ -31,6 +31,7 @@
acks_late_micro_short_task,
)
from grandchallenge.evaluation.models import Evaluation, Phase
from grandchallenge.invoices.models import Invoice


@acks_late_2xlarge_task
Expand Down Expand Up @@ -78,7 +79,13 @@ def update_challenge_results_cache():
)


def retry_with_backoff(exceptions, max_attempts=5, base_delay=1, max_delay=10):
def retry_with_backoff(
*,
Comment thread
chrisvanrun marked this conversation as resolved.
Outdated
exceptions,
max_attempts=5,
base_delay=1,
max_delay=10,
):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
Expand All @@ -102,23 +109,34 @@ def wrapper(*args, **kwargs):

@acks_late_2xlarge_task
def update_challenge_compute_costs():
for challenge in Challenge.objects.with_available_compute().iterator(
chunk_size=1000
):
for invoice in Invoice.objects.iterator(chunk_size=1000):
with transaction.atomic():
annotate_compute_costs(challenge=challenge)
annotate_compute_costs(invoice=invoice)

@retry_with_backoff((LockNotAvailable,))
def save_challenge():
@retry_with_backoff(exceptions=(LockNotAvailable,))
def save_invoice():
invoice.save(
update_fields=("compute_costs_utilized_euros_millicents",)
)

# TODO: remove this once compute_cost_euro_millicents is removed or deprecated
Comment thread
chrisvanrun marked this conversation as resolved.
challenge = Challenge.objects.with_available_compute().get(
pk=invoice.challenge_id
)
challenge.compute_cost_euro_millicents = (
Invoice.objects.filter(challenge=challenge.pk).aggregate(
total=Sum("compute_costs_utilized_euros_millicents")
)["total"]
)
challenge.save(update_fields=("compute_cost_euro_millicents",))

save_challenge()
save_invoice()

for phase in Phase.objects.iterator(chunk_size=1000):
with transaction.atomic():
annotate_job_duration_and_compute_costs(phase=phase)

@retry_with_backoff((LockNotAvailable,))
@retry_with_backoff(exceptions=(LockNotAvailable,))
def save_phase():
phase.save(
skip_calculate_ranks=True,
Expand All @@ -137,7 +155,7 @@ def update_challenge_storage_size():
with transaction.atomic():
annotate_storage_size(challenge=challenge)

@retry_with_backoff((LockNotAvailable,))
@retry_with_backoff(exceptions=(LockNotAvailable,))
def save_challenge():
challenge.save(
update_fields=(
Expand Down
10 changes: 6 additions & 4 deletions app/grandchallenge/components/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1661,6 +1661,11 @@ def retrieve_existing_civs(*, civ_data_objects):

return existing_civs

def create(self, *args, utilization_invoice=None, **kwargs):
component_job = super().create(*args, **kwargs)
component_job.create_utilization(invoice=utilization_invoice)
return component_job
Comment thread
chrisvanrun marked this conversation as resolved.
Outdated


class ComponentJob(FieldChangeMixin, UUIDModel):
# The job statuses come directly from celery.result.AsyncResult.status:
Expand Down Expand Up @@ -1818,9 +1823,6 @@ def save(self, *args, **kwargs):

super().save()

if adding:
self.create_utilization()

def update_status( # noqa:C901
self,
*,
Expand Down Expand Up @@ -2032,7 +2034,7 @@ def runtime_metrics_chart(self):
],
)

def create_utilization(self):
def create_utilization(self, *, invoice):
raise NotImplementedError

@property
Expand Down
15 changes: 14 additions & 1 deletion app/grandchallenge/evaluation/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.forms import ModelForm
from django.utils.html import format_html

from grandchallenge.challenges.exceptions import InsufficientBudgetError
from grandchallenge.components.admin import (
ComponentImageAdmin,
cancel_jobs,
Expand Down Expand Up @@ -130,7 +131,19 @@ def reevaluate_submissions(modeladmin, request, queryset):
messages.WARNING,
)
else:
submission.create_evaluation(additional_inputs=None)
try:
invoice = submission.phase.challenge.active_invoice
except InsufficientBudgetError:
modeladmin.message_user(
request,
f"Submission {submission.pk} cannot be reevaluated. Challenge has insufficient budget.",
messages.WARNING,
)
else:
submission.create_evaluation(
additional_inputs=None,
invoice=invoice,
)


@admin.register(Submission)
Expand Down
31 changes: 19 additions & 12 deletions app/grandchallenge/evaluation/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from grandchallenge.algorithms.forms import UserAlgorithmsForPhaseMixin
from grandchallenge.algorithms.models import Job
from grandchallenge.challenges.models import Challenge
from grandchallenge.challenges.exceptions import InsufficientBudgetError
from grandchallenge.components.forms import (
AdditionalInputsMixin,
ContainerImageForm,
Expand Down Expand Up @@ -471,6 +471,15 @@ def clean(self):
"You must confirm that you want to submit to this phase."
)

try:
invoice = self._phase.challenge.active_invoice
except InsufficientBudgetError:
raise ValidationError(
"Challenge has insufficient budget. Please contact the challenge organizers."
)
else:
cleaned_data["invoice"] = invoice

return cleaned_data

def clean_phase(self):
Expand Down Expand Up @@ -655,7 +664,8 @@ def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)

instance.create_evaluation(
additional_inputs=self.cleaned_data["additional_inputs"]
additional_inputs=self.cleaned_data["additional_inputs"],
invoice=self.cleaned_data["invoice"],
)

return instance
Expand Down Expand Up @@ -741,17 +751,14 @@ def clean(self):
"Please wait for the other evaluation to complete."
)

# Fetch from the db to get the cost annotations
challenge = (
Challenge.objects.filter(
pk=cleaned_data["submission"].phase.challenge.pk
try:
invoice = cleaned_data["submission"].phase.challenge.active_invoice
except InsufficientBudgetError:
raise ValidationError(
"Challenge has insufficient budget. Please contact the challenge organizers."
Comment thread
chrisvanrun marked this conversation as resolved.
Outdated
)
.with_available_compute()
.get()
)

if challenge.available_compute_euro_millicents <= 0:
raise ValidationError("This challenge has exceeded its budget")
else:
cleaned_data["invoice"] = invoice

if Evaluation.objects.get_evaluations_with_same_inputs(
inputs=cleaned_data["additional_inputs"],
Expand Down
7 changes: 4 additions & 3 deletions app/grandchallenge/evaluation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1651,7 +1651,7 @@ def save(self, *args, **kwargs):
send_action=False,
)

def create_evaluation(self, *, additional_inputs):
def create_evaluation(self, *, additional_inputs, invoice):
if (
self.phase.additional_evaluation_inputs.exists()
and not additional_inputs
Expand Down Expand Up @@ -1711,6 +1711,7 @@ def create_evaluation(self, *, additional_inputs):
requires_gpu_type=self.phase.evaluation_requires_gpu_type,
requires_memory_gb=self.phase.evaluation_requires_memory_gb,
status=Evaluation.VALIDATING_INPUTS,
utilization_invoice=invoice,
)

if self.phase.submission_kind == SubmissionKindChoices.ALGORITHM:
Expand Down Expand Up @@ -2459,8 +2460,8 @@ def status_url(self) -> str:
},
)

def create_utilization(self):
EvaluationUtilization.objects.create(evaluation=self)
def create_utilization(self, *, invoice):
EvaluationUtilization.objects.create(evaluation=self, invoice=invoice)

@property
def utilization(self):
Expand Down
3 changes: 2 additions & 1 deletion app/grandchallenge/evaluation/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,8 @@ def get_success_url(self):
def form_valid(self, form):
redirect = super().form_valid(form)
self.submission.create_evaluation(
additional_inputs=form.cleaned_data["additional_inputs"]
additional_inputs=form.cleaned_data["additional_inputs"],
invoice=form.cleaned_data["invoice"],
)
return redirect

Expand Down
Loading
Loading