Skip to content

Commit d89ac57

Browse files
authored
Cache challenge data for faster loading of /api/v1/challenges (CTFd#2232)
* Improve response time of `/api/v1/challenges` and `/api/v1/challenges/[challenge_id]/solves` * Rewrite and remove _build_solves_query to make it cacheable * Closes CTFd#2209
1 parent 800fb82 commit d89ac57

File tree

12 files changed

+356
-132
lines changed

12 files changed

+356
-132
lines changed

CTFd/admin/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@
2626
from CTFd.admin import submissions # noqa: F401
2727
from CTFd.admin import teams # noqa: F401
2828
from CTFd.admin import users # noqa: F401
29-
from CTFd.cache import cache, clear_config, clear_pages, clear_standings
29+
from CTFd.cache import (
30+
cache,
31+
clear_challenges,
32+
clear_config,
33+
clear_pages,
34+
clear_standings,
35+
)
3036
from CTFd.models import (
3137
Awards,
3238
Challenges,
@@ -238,6 +244,7 @@ def reset():
238244

239245
clear_pages()
240246
clear_standings()
247+
clear_challenges()
241248
clear_config()
242249

243250
if logout is True:

CTFd/api/v1/challenges.py

Lines changed: 47 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import datetime
21
from typing import List
32

43
from flask import abort, render_template, request, url_for
54
from flask_restx import Namespace, Resource
6-
from sqlalchemy import func as sa_func
7-
from sqlalchemy.sql import and_, false, true
5+
from sqlalchemy.sql import and_
86

97
from CTFd.api.v1.helpers.request import validate_args
108
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
119
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
12-
from CTFd.cache import clear_standings
10+
from CTFd.cache import clear_challenges, clear_standings
1311
from CTFd.constants import RawEnum
1412
from CTFd.models import ChallengeFiles as ChallengeFilesModel
1513
from CTFd.models import Challenges
@@ -22,12 +20,18 @@
2220
from CTFd.schemas.tags import TagSchema
2321
from CTFd.utils import config, get_config
2422
from CTFd.utils import user as current_user
23+
from CTFd.utils.challenges import (
24+
get_all_challenges,
25+
get_solve_counts_for_challenges,
26+
get_solve_ids_for_user_id,
27+
get_solves_for_challenge_id,
28+
)
2529
from CTFd.utils.config.visibility import (
2630
accounts_visible,
2731
challenges_visible,
2832
scores_visible,
2933
)
30-
from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime, isoformat, unix_time_to_utc
34+
from CTFd.utils.dates import ctf_ended, ctf_paused, ctftime
3135
from CTFd.utils.decorators import (
3236
admins_only,
3337
during_ctf_time_only,
@@ -37,9 +41,7 @@
3741
check_challenge_visibility,
3842
check_score_visibility,
3943
)
40-
from CTFd.utils.helpers.models import build_model_filters
4144
from CTFd.utils.logging import log
42-
from CTFd.utils.modes import generate_account_url, get_model
4345
from CTFd.utils.security.signing import serialize
4446
from CTFd.utils.user import (
4547
authed,
@@ -77,60 +79,6 @@ class ChallengeListSuccessResponse(APIListSuccessResponse):
7779
)
7880

7981

80-
def _build_solves_query(extra_filters=(), admin_view=False):
81-
"""Returns queries and data that that are used for showing an account's solves.
82-
It returns a tuple of
83-
- SQLAlchemy query with (challenge_id, solve_count_for_challenge_id)
84-
- Current user's solved challenge IDs
85-
"""
86-
# This can return None (unauth) if visibility is set to public
87-
user = get_current_user()
88-
# We only set a condition for matching user solves if there is a user and
89-
# they have an account ID (user mode or in a team in teams mode)
90-
AccountModel = get_model()
91-
if user is not None and user.account_id is not None:
92-
user_solved_cond = Solves.account_id == user.account_id
93-
else:
94-
user_solved_cond = false()
95-
# We have to filter solves to exclude any made after the current freeze
96-
# time unless we're in an admin view as determined by the caller.
97-
freeze = get_config("freeze")
98-
if freeze and not admin_view:
99-
freeze_cond = Solves.date < unix_time_to_utc(freeze)
100-
else:
101-
freeze_cond = true()
102-
# Finally, we never count solves made by hidden or banned users/teams, even
103-
# if we are an admin. This is to match the challenge detail API.
104-
exclude_solves_cond = and_(
105-
AccountModel.banned == false(), AccountModel.hidden == false(),
106-
)
107-
# This query counts the number of solves per challenge, as well as the sum
108-
# of correct solves made by the current user per the condition above (which
109-
# should probably only be 0 or 1!)
110-
solves_q = (
111-
db.session.query(Solves.challenge_id, sa_func.count(Solves.challenge_id),)
112-
.join(AccountModel)
113-
.filter(*extra_filters, freeze_cond, exclude_solves_cond)
114-
.group_by(Solves.challenge_id)
115-
)
116-
# Also gather the user's solve items which can be different from above query
117-
# For example, even if we are a hidden user, we should see that we have solved a challenge
118-
# however as a hidden user we are not included in the count of the above query
119-
if admin_view:
120-
# If we're an admin we should show all challenges as solved to break through any requirements
121-
challenges = Challenges.query.all()
122-
solve_ids = {challenge.id for challenge in challenges}
123-
else:
124-
# If not an admin we calculate solves as normal
125-
solve_ids = (
126-
Solves.query.with_entities(Solves.challenge_id)
127-
.filter(user_solved_cond)
128-
.all()
129-
)
130-
solve_ids = {value for value, in solve_ids}
131-
return solves_q, solve_ids
132-
133-
13482
@challenges_namespace.route("")
13583
class ChallengeList(Resource):
13684
@check_challenge_visibility
@@ -185,23 +133,22 @@ def get(self, query_args):
185133
# Build filtering queries
186134
q = query_args.pop("q", None)
187135
field = str(query_args.pop("field", None))
188-
filters = build_model_filters(model=Challenges, query=q, field=field)
189136

190137
# Admins get a shortcut to see all challenges despite pre-requisites
191138
admin_view = is_admin() and request.args.get("view") == "admin"
192139

193-
solve_counts = {}
194-
# Build a query for to show challenge solve information. We only
195-
# give an admin view if the request argument has been provided.
196-
#
197-
# NOTE: This is different behaviour to the challenge detail
198-
# endpoint which only needs the current user to be an admin rather
199-
# than also also having to provide `view=admin` as a query arg.
200-
solves_q, user_solves = _build_solves_query(admin_view=admin_view)
140+
# Get a cached mapping of challenge_id to solve_count
141+
solve_counts = get_solve_counts_for_challenges(admin=admin_view)
142+
143+
# Get list of solve_ids for current user
144+
if authed():
145+
user = get_current_user()
146+
user_solves = get_solve_ids_for_user_id(user_id=user.id)
147+
else:
148+
user_solves = set()
149+
201150
# Aggregate the query results into the hashes defined at the top of
202151
# this block for later use
203-
for chal_id, solve_count in solves_q:
204-
solve_counts[chal_id] = solve_count
205152
if scores_visible() and accounts_visible():
206153
solve_count_dfl = 0
207154
else:
@@ -211,18 +158,7 @@ def get(self, query_args):
211158
# `None` for the solve count if visiblity checks fail
212159
solve_count_dfl = None
213160

214-
# Build the query for the challenges which may be listed
215-
chal_q = Challenges.query
216-
# Admins can see hidden and locked challenges in the admin view
217-
if admin_view is False:
218-
chal_q = chal_q.filter(
219-
and_(Challenges.state != "hidden", Challenges.state != "locked")
220-
)
221-
chal_q = (
222-
chal_q.filter_by(**query_args)
223-
.filter(*filters)
224-
.order_by(Challenges.value, Challenges.id)
225-
)
161+
chal_q = get_all_challenges(admin=admin_view, field=field, q=q, **query_args)
226162

227163
# Iterate through the list of challenges, adding to the object which
228164
# will be JSONified back to the client
@@ -308,6 +244,9 @@ def post(self):
308244
challenge_class = get_chal_class(challenge_type)
309245
challenge = challenge_class.create(request)
310246
response = challenge_class.read(challenge)
247+
248+
clear_challenges()
249+
311250
return {"success": True, "data": response}
312251

313252

@@ -453,13 +392,17 @@ def get(self, challenge_id):
453392

454393
response = chal_class.read(challenge=chal)
455394

456-
solves_q, user_solves = _build_solves_query(
457-
extra_filters=(Solves.challenge_id == chal.id,)
458-
)
459-
# If there are no solves for this challenge ID then we have 0 rows
460-
maybe_row = solves_q.first()
461-
if maybe_row:
462-
challenge_id, solve_count = maybe_row
395+
# Get list of solve_ids for current user
396+
if authed():
397+
user = get_current_user()
398+
user_solves = get_solve_ids_for_user_id(user_id=user.id)
399+
else:
400+
user_solves = []
401+
402+
solves_count = get_solve_counts_for_challenges(challenge_id=chal.id)
403+
if solves_count:
404+
challenge_id = chal.id
405+
solve_count = solves_count.get(chal.id)
463406
solved_by_user = challenge_id in user_solves
464407
else:
465408
solve_count, solved_by_user = 0, False
@@ -522,6 +465,10 @@ def patch(self, challenge_id):
522465
challenge_class = get_chal_class(challenge.type)
523466
challenge = challenge_class.update(challenge, request)
524467
response = challenge_class.read(challenge)
468+
469+
clear_standings()
470+
clear_challenges()
471+
525472
return {"success": True, "data": response}
526473

527474
@admins_only
@@ -534,6 +481,9 @@ def delete(self, challenge_id):
534481
chal_class = get_chal_class(challenge.type)
535482
chal_class.delete(challenge)
536483

484+
clear_standings()
485+
clear_challenges()
486+
537487
return {"success": True}
538488

539489

@@ -675,6 +625,7 @@ def post(self):
675625
user=user, team=team, challenge=challenge, request=request
676626
)
677627
clear_standings()
628+
clear_challenges()
678629

679630
log(
680631
"submissions",
@@ -694,6 +645,7 @@ def post(self):
694645
user=user, team=team, challenge=challenge, request=request
695646
)
696647
clear_standings()
648+
clear_challenges()
697649

698650
log(
699651
"submissions",
@@ -762,41 +714,15 @@ def get(self, challenge_id):
762714
if challenge.state == "hidden" and is_admin() is False:
763715
abort(404)
764716

765-
Model = get_model()
766-
767-
# Note that we specifically query for the Solves.account.name
768-
# attribute here because it is faster than having SQLAlchemy
769-
# query for the attribute directly and it's unknown what the
770-
# affects of changing the relationship lazy attribute would be
771-
solves = (
772-
Solves.query.add_columns(Model.name.label("account_name"))
773-
.join(Model, Solves.account_id == Model.id)
774-
.filter(
775-
Solves.challenge_id == challenge_id,
776-
Model.banned == False,
777-
Model.hidden == False,
778-
)
779-
.order_by(Solves.date.asc())
780-
)
781-
782717
freeze = get_config("freeze")
783718
if freeze:
784719
preview = request.args.get("preview")
785720
if (is_admin() is False) or (is_admin() is True and preview):
786-
dt = datetime.datetime.utcfromtimestamp(freeze)
787-
solves = solves.filter(Solves.date < dt)
721+
freeze = True
722+
elif is_admin() is True:
723+
freeze = False
788724

789-
for solve in solves:
790-
# Seperate out the account name and the Solve object from the SQLAlchemy tuple
791-
solve, account_name = solve
792-
response.append(
793-
{
794-
"account_id": solve.account_id,
795-
"name": account_name,
796-
"date": isoformat(solve.date),
797-
"account_url": generate_account_url(account_id=solve.account_id),
798-
}
799-
)
725+
response = get_solves_for_challenge_id(challenge_id=challenge_id, freeze=freeze)
800726

801727
return {"success": True, "data": response}
802728

CTFd/api/v1/config.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from CTFd.api.v1.helpers.request import validate_args
77
from CTFd.api.v1.helpers.schemas import sqlalchemy_to_pydantic
88
from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessResponse
9-
from CTFd.cache import clear_config, clear_standings
9+
from CTFd.cache import clear_challenges, clear_config, clear_standings
1010
from CTFd.constants import RawEnum
1111
from CTFd.models import Configs, Fields, db
1212
from CTFd.schemas.config import ConfigSchema
@@ -99,6 +99,7 @@ def post(self):
9999

100100
clear_config()
101101
clear_standings()
102+
clear_challenges()
102103

103104
return {"success": True, "data": response.data}
104105

@@ -119,6 +120,7 @@ def patch(self):
119120

120121
clear_config()
121122
clear_standings()
123+
clear_challenges()
122124

123125
return {"success": True}
124126

@@ -175,6 +177,7 @@ def patch(self, config_key):
175177

176178
clear_config()
177179
clear_standings()
180+
clear_challenges()
178181

179182
return {"success": True, "data": response.data}
180183

@@ -192,6 +195,7 @@ def delete(self, config_key):
192195

193196
clear_config()
194197
clear_standings()
198+
clear_challenges()
195199

196200
return {"success": True}
197201

CTFd/api/v1/submissions.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
APIDetailedSuccessResponse,
99
PaginatedAPIListSuccessResponse,
1010
)
11-
from CTFd.cache import clear_standings
11+
from CTFd.cache import clear_challenges, clear_standings
1212
from CTFd.constants import RawEnum
1313
from CTFd.models import Submissions, db
1414
from CTFd.schemas.submissions import SubmissionSchema
@@ -141,6 +141,8 @@ def post(self, json_args):
141141

142142
# Delete standings cache
143143
clear_standings()
144+
# Delete challenges cache
145+
clear_challenges()
144146

145147
return {"success": True, "data": response.data}
146148

@@ -188,5 +190,6 @@ def delete(self, submission_id):
188190

189191
# Delete standings cache
190192
clear_standings()
193+
clear_challenges()
191194

192195
return {"success": True}

0 commit comments

Comments
 (0)