Skip to content

Commit a0565a0

Browse files
committed
WIP consolidation task
1 parent f9c162e commit a0565a0

3 files changed

Lines changed: 232 additions & 9 deletions

File tree

app/grandchallenge/challenges/costs.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
AlgorithmModel,
77
Job,
88
)
9+
from dateutil.utils import today
10+
911
from grandchallenge.cases.models import ImageFile
1012
from grandchallenge.components.models import ComponentInterfaceValue
1113
from grandchallenge.evaluation.models import (
@@ -20,6 +22,51 @@
2022
)
2123

2224

25+
def get_compute_costs_for_invoice(*, invoice, limit_to_covered_period):
26+
27+
algorithm_job_utilizations = JobUtilization.objects.filter(
28+
challenge=invoice.challenge
29+
)
30+
job_warm_pool_utilizations = JobWarmPoolUtilization.objects.filter(
31+
challenge=invoice.challenge
32+
)
33+
evaluation_job_utilizations = EvaluationUtilization.objects.filter(
34+
challenge=invoice.challenge
35+
)
36+
37+
if limit_to_covered_period:
38+
algorithm_job_utilizations = algorithm_job_utilizations.filter(
39+
created__lte=invoice.expires_on
40+
)
41+
job_warm_pool_utilizations = job_warm_pool_utilizations.filter(
42+
created__lte=invoice.expires_on
43+
)
44+
evaluation_job_utilizations = evaluation_job_utilizations.filter(
45+
created__lte=invoice.expires_on
46+
)
47+
48+
algorithm_job_costs = algorithm_job_utilizations.aggregate(
49+
Sum("compute_cost_euro_millicents")
50+
)
51+
job_warm_pool_costs = job_warm_pool_utilizations.aggregate(
52+
Sum("compute_cost_euro_millicents")
53+
)
54+
evaluation_costs = evaluation_job_utilizations.aggregate(
55+
Sum("compute_cost_euro_millicents")
56+
)
57+
58+
items = [algorithm_job_costs, job_warm_pool_costs, evaluation_costs]
59+
60+
total = sum(
61+
item["compute_cost_euro_millicents__sum"] or 0 for item in items
62+
)
63+
64+
if limit_to_covered_period:
65+
total = min(total, invoice.compute_costs_euros * 1000 * 100)
66+
67+
return total
68+
69+
2370
def annotate_compute_costs(*, challenge):
2471
algorithm_job_utilizations = JobUtilization.objects.filter(
2572
challenge=challenge

app/grandchallenge/challenges/tasks.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
from django.conf import settings
77
from django.contrib.auth import get_user_model
88
from django.db import transaction
9-
from django.db.models import Count, Max, Min, Q
9+
from django.db.models import Count, Max, Min, Q, Sum
1010
from django.utils.timezone import datetime, now
1111
from psycopg.errors import LockNotAvailable
1212

1313
from grandchallenge.challenges.costs import (
1414
annotate_compute_costs,
1515
annotate_job_duration_and_compute_costs,
1616
annotate_storage_size,
17+
get_compute_costs_for_invoice,
1718
)
1819
from grandchallenge.challenges.emails import (
1920
send_challenge_requests_draft_reminder,
@@ -31,6 +32,7 @@
3132
acks_late_micro_short_task,
3233
)
3334
from grandchallenge.evaluation.models import Evaluation, Phase
35+
from grandchallenge.invoices.models import Invoice
3436

3537

3638
@acks_late_2xlarge_task
@@ -102,17 +104,50 @@ def wrapper(*args, **kwargs):
102104

103105
@acks_late_2xlarge_task
104106
def update_challenge_compute_costs():
105-
for challenge in Challenge.objects.with_available_compute().iterator(
106-
chunk_size=1000
107-
):
107+
challenge_ids = Invoice.objects.values_list(
108+
"challenge_id", flat=True
109+
).distinct()
110+
111+
for challenge_id in challenge_ids:
108112
with transaction.atomic():
109-
annotate_compute_costs(challenge=challenge)
113+
invoices = list(
114+
Invoice.objects.filter(challenge_id=challenge_id).order_by(
115+
"expires_on", "created"
116+
) # Todo: add queryset-level filter to only return those invoices with authorized budget
117+
)
118+
119+
covered_compute_costs = 0
120+
last_index = len(invoices) - 1
121+
for idx, invoice in enumerate(invoices):
122+
is_last = idx == last_index
123+
compute_costs = get_compute_costs_for_invoice(
124+
invoice=invoice,
125+
limit_to_covered_period=not is_last,
126+
)
127+
invoice.compute_cost_euro_millicents = (
128+
compute_costs - covered_compute_costs
129+
)
130+
covered_compute_costs = compute_costs
110131

111132
@retry_with_backoff((LockNotAvailable,))
112-
def save_challenge():
133+
def save_invoices():
134+
Invoice.objects.bulk_update(
135+
invoices,
136+
["compute_cost_euro_millicents"],
137+
)
138+
139+
# TODO: remove this once compute_cost_euro_millicents is removed or deprecated
140+
challenge = Challenge.objects.with_available_compute().get(
141+
pk=challenge_id
142+
)
143+
challenge.compute_cost_euro_millicents = (
144+
Invoice.objects.filter(challenge=challenge_id).aggregate(
145+
total=Sum("compute_cost_euro_millicents")
146+
)["total"]
147+
)
113148
challenge.save(update_fields=("compute_cost_euro_millicents",))
114149

115-
save_challenge()
150+
save_invoices()
116151

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

app/tests/challenges_tests/test_tasks.py

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from zoneinfo import ZoneInfo
22

3+
from django.utils import timezone
34
import pytest
45
from django.core import mail
5-
from django.utils.timezone import datetime, timedelta
6+
from django.utils.timezone import datetime, now, timedelta
67

8+
from grandchallenge.algorithms.models import Job
79
from grandchallenge.challenges.models import (
810
Challenge,
911
ChallengeRequest,
@@ -15,7 +17,13 @@
1517
update_challenge_compute_costs,
1618
update_challenge_results_cache,
1719
)
18-
from grandchallenge.invoices.models import PaymentStatusChoices
20+
from grandchallenge.invoices.models import (
21+
PaymentStatusChoices,
22+
PaymentTypeChoices,
23+
)
24+
from grandchallenge.utilization.models import JobWarmPoolUtilization
25+
from grandchallenge.utilization.tasks import create_job_warm_pool_utilizations
26+
from tests.algorithms_tests.factories import AlgorithmJobFactory
1927
from tests.evaluation_tests.factories import EvaluationFactory, PhaseFactory
2028
from tests.factories import (
2129
ChallengeFactory,
@@ -444,3 +452,136 @@ def test_challenge_request_draft_reminder_emails(
444452
]
445453
assert len(user_emails) == 1
446454
assert requests[i].title in user_emails[0].body
455+
456+
457+
@pytest.mark.django_db
458+
def test_update_challenge_compute_costs_no_utilization():
459+
challenge = ChallengeFactory()
460+
invoice = InvoiceFactory(challenge=challenge)
461+
462+
assert invoice.compute_cost_euro_millicents == 0
463+
update_challenge_compute_costs()
464+
invoice.refresh_from_db()
465+
assert invoice.compute_cost_euro_millicents == 0
466+
467+
468+
@pytest.mark.django_db
469+
def test_update_challenge_compute_costs(settings):
470+
471+
settings.COMPONENTS_DEFAULT_BACKEND = (
472+
"tests.utilization_tests.test_tasks.UtilizationExecutor"
473+
)
474+
475+
challenge = ChallengeFactory()
476+
invoice = InvoiceFactory(challenge=challenge)
477+
phase = PhaseFactory(challenge=challenge)
478+
479+
evaluation = EvaluationFactory(
480+
submission__phase=phase,
481+
time_limit=60,
482+
)
483+
evaluation_utilization = evaluation.evaluation_utilization
484+
evaluation_utilization.invoice = invoice
485+
evaluation_utilization.compute_cost_euro_millicents = 1
486+
evaluation_utilization.save()
487+
488+
job = AlgorithmJobFactory(
489+
status=Job.SUCCESS,
490+
use_warm_pool=True,
491+
time_limit=60,
492+
)
493+
494+
job_utilization = job.job_utilization
495+
job_utilization.phase = phase
496+
job_utilization.challenge = challenge
497+
job_utilization.invoice = invoice
498+
job_utilization.compute_cost_euro_millicents = 2
499+
job_utilization.save()
500+
501+
job2 = AlgorithmJobFactory(
502+
status=Job.SUCCESS,
503+
use_warm_pool=True,
504+
time_limit=60,
505+
)
506+
507+
job2_utilization = job2.job_utilization
508+
job2_utilization.phase = phase
509+
job2_utilization.challenge = challenge
510+
job2_utilization.invoice = invoice
511+
job2_utilization.compute_cost_euro_millicents = 4
512+
job2_utilization.save()
513+
514+
assert not JobWarmPoolUtilization.objects.exists()
515+
create_job_warm_pool_utilizations()
516+
for job_warm_pool_utilization in JobWarmPoolUtilization.objects.all():
517+
job_warm_pool_utilization.compute_cost_euro_millicents = 8
518+
job_warm_pool_utilization.save()
519+
520+
assert invoice.compute_cost_euro_millicents == 0
521+
522+
update_challenge_compute_costs()
523+
524+
invoice.refresh_from_db()
525+
assert invoice.compute_cost_euro_millicents == 1 + 2 + 4 + 8 + 8
526+
527+
528+
@pytest.mark.django_db
529+
def test_update_challenge_compute_costs_expired_utilization():
530+
531+
challenge = ChallengeFactory()
532+
533+
invoice_last_year = InvoiceFactory(
534+
challenge=challenge,
535+
compute_costs_euros=10,
536+
payment_status=PaymentStatusChoices.PAID,
537+
payment_type=PaymentTypeChoices.PREPAID,
538+
expires_on=now().date() - timedelta(days=365),
539+
)
540+
541+
invoice_this_year = InvoiceFactory(
542+
challenge=challenge,
543+
compute_costs_euros=20,
544+
payment_status=PaymentStatusChoices.PAID,
545+
payment_type=PaymentTypeChoices.PREPAID,
546+
expires_on=now().date(),
547+
)
548+
549+
invoice_later_this_year = InvoiceFactory(
550+
challenge=challenge,
551+
compute_costs_euros=40,
552+
payment_status=PaymentStatusChoices.PAID,
553+
payment_type=PaymentTypeChoices.PREPAID,
554+
expires_on=now().date() + timedelta(days=365),
555+
)
556+
557+
job_last_year = AlgorithmJobFactory(
558+
status=Job.SUCCESS,
559+
time_limit=60,
560+
)
561+
job_utilization = job_last_year.job_utilization
562+
job_utilization.challenge = challenge
563+
job_utilization.created = now() - timedelta(days=365)
564+
job_utilization.compute_cost_euro_millicents = (
565+
100 * 1000 * 100 # Note: Huge
566+
)
567+
job_utilization.save()
568+
569+
assert invoice_last_year.compute_cost_euro_millicents == 0
570+
assert invoice_this_year.compute_cost_euro_millicents == 0
571+
assert invoice_later_this_year.compute_cost_euro_millicents == 0
572+
573+
update_challenge_compute_costs()
574+
575+
invoice_last_year.refresh_from_db()
576+
assert ( # Fully utilized
577+
invoice_last_year.compute_cost_euro_millicents == 10 * 1000 * 100
578+
)
579+
assert ( # Fully utilized
580+
invoice_this_year.compute_cost_euro_millicents == 20 * 1000 * 100
581+
)
582+
assert ( # Utilization from earlier bubble up to this one
583+
invoice_later_this_year.compute_cost_euro_millicents == 70 * 1000 * 100
584+
)
585+
586+
587+
# TODO: add test that checks if we filter on authorized budget when calculating compute costs for invoices

0 commit comments

Comments
 (0)