Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
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 (
provision_invocation_input_data,
Expand Down Expand Up @@ -165,6 +166,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 @@ -173,8 +189,6 @@ def create_algorithm_jobs(

job.execute()

jobs.append(job)

return jobs


Expand Down
19 changes: 12 additions & 7 deletions app/grandchallenge/challenges/costs.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,20 @@
)


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

update_compute_cost_euro_millicents(
obj=challenge,
obj=invoice,
obj_field="compute_costs_utilized_euros_millicents",
algorithm_job_utilizations=algorithm_job_utilizations,
job_warm_pool_utilizations=job_warm_pool_utilizations,
evaluation_job_utilizations=evaluation_job_utilizations,
Expand All @@ -54,6 +55,7 @@ def annotate_job_duration_and_compute_costs(*, phase):

update_compute_cost_euro_millicents(
obj=phase,
obj_field="compute_cost_euro_millicents",
algorithm_job_utilizations=algorithm_job_utilizations,
job_warm_pool_utilizations=job_warm_pool_utilizations,
evaluation_job_utilizations=evaluation_job_utilizations,
Expand All @@ -63,6 +65,7 @@ def annotate_job_duration_and_compute_costs(*, phase):
def update_compute_cost_euro_millicents(
*,
obj,
obj_field,
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.

Why is this new kwarg necessary?

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.

I see now. The construction indicates a design problem. Please rename compute_costs_utilized_euros_millicents to compute_cost_euro_millicents on invoices.

This is for several reasons:

  • This is the name suggested by the method being used here: update_compute_cost_euro_millicents
  • Internal consistency: attributes that represent the same thing should be called the same thing
  • Historical consistency: we already have an existing name for this property
  • Simplified implementation: reduced complexity in passing and setting these attributes, no need for extra kwargs or setattr
  • Reduced cognitive load: developers do not need to thing about what objects they are dealing with when accessing this attribute

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The name was chosen deliberately to contrast it with the existing Invoice.compute_costs_euros. Having these side by side is easy to confuse:

  • Invoice.compute_costs_euros
  • Invoice.compute_cost_euro_millicents

Challenge.compute_cost_euro_millicents will eventually be removed as it can be replaced by a simple query on the Invoice table.

Rather than renaming the Invoice.compute_costs_utilized_euros_millicents, would it make more sense to rename the Phase.compute_cost_euro_millicents to align with it (i.e. Phase.compute_costs_utilized_euros_millicents)?

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.

That cannot be done without downtime and/or a data migration, so rename the new one.

algorithm_job_utilizations,
job_warm_pool_utilizations,
evaluation_job_utilizations,
Expand All @@ -79,8 +82,10 @@ def update_compute_cost_euro_millicents(

items = [algorithm_job_costs, job_warm_pool_costs, evaluation_costs]

obj.compute_cost_euro_millicents = sum(
item["compute_cost_euro_millicents__sum"] or 0 for item in items
setattr(
obj,
obj_field,
sum(item["compute_cost_euro_millicents__sum"] or 0 for item in items),
)


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
17 changes: 17 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,22 @@ def has_paid_prepaid_invoice(self):
payment_status=PaymentStatusChoices.PAID,
).exists()

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

total_balance = sum(
invoice.compute_costs_balance_euros_millicents
for invoice in invoices
)

if total_balance <= 0:
raise InsufficientBudgetError

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


class ChallengeUserObjectPermission(UserObjectPermissionBase):
allowed_permissions = frozenset()
Expand Down
26 changes: 19 additions & 7 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 @@ -102,17 +103,28 @@ 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():
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():
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 @@ -169,7 +170,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 support to add more funds."
)
.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
4 changes: 3 additions & 1 deletion 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 @@ -1712,6 +1712,8 @@ def create_evaluation(self, *, additional_inputs):
requires_memory_gb=self.phase.evaluation_requires_memory_gb,
status=Evaluation.VALIDATING_INPUTS,
)
evaluation.utilization.invoice = invoice
evaluation.utilization.save()

if self.phase.submission_kind == SubmissionKindChoices.ALGORITHM:
if not self.has_matching_algorithm_interfaces:
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
9 changes: 9 additions & 0 deletions app/grandchallenge/utilization/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ class JobUtilizationAdmin(admin.ModelAdmin):
"creator",
"duration",
"compute_cost_euro_millicents",
"invoice",
"phase",
"challenge",
"algorithm_image",
Expand All @@ -95,6 +96,7 @@ class JobUtilizationAdmin(admin.ModelAdmin):
list_select_related = (
"job",
"creator",
"invoice",
"phase",
"challenge",
"algorithm_image",
Expand All @@ -109,6 +111,7 @@ class JobUtilizationAdmin(admin.ModelAdmin):
)
readonly_fields = (
"creator",
"invoice",
"phase",
"challenge",
"archive",
Expand All @@ -130,6 +133,7 @@ class JobWarmPoolUtilizationAdmin(admin.ModelAdmin):
"creator",
"duration",
"compute_cost_euro_millicents",
"invoice",
"phase",
"challenge",
"algorithm_image",
Expand All @@ -138,6 +142,7 @@ class JobWarmPoolUtilizationAdmin(admin.ModelAdmin):
list_select_related = (
"job",
"creator",
"invoice",
"phase",
"challenge",
"algorithm_image",
Expand All @@ -152,6 +157,7 @@ class JobWarmPoolUtilizationAdmin(admin.ModelAdmin):
)
readonly_fields = (
"creator",
"invoice",
"phase",
"challenge",
"archive",
Expand All @@ -175,6 +181,7 @@ class EvaluationUtilizationAdmin(admin.ModelAdmin):
"creator",
"duration",
"compute_cost_euro_millicents",
"invoice",
"phase",
"challenge",
"algorithm_image",
Expand All @@ -183,6 +190,7 @@ class EvaluationUtilizationAdmin(admin.ModelAdmin):
list_select_related = (
"evaluation",
"creator",
"invoice",
"phase",
"challenge",
"algorithm_image",
Expand All @@ -197,6 +205,7 @@ class EvaluationUtilizationAdmin(admin.ModelAdmin):
)
readonly_fields = (
"creator",
"invoice",
"phase",
"challenge",
"archive",
Expand Down
Loading
Loading