Skip to content

Commit 53bdff5

Browse files
ewdurbinclaude
andcommitted
extract shared query helpers to eliminate duplicated N+1 patterns
Moves identical batch-query code from organization(), projects(), project(), and project_application() into cabotage/server/query_helpers.py. Fixes remaining N+1 in application_releases() (~60 queries/page → ~3). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3e8d6b8 commit 53bdff5

3 files changed

Lines changed: 420 additions & 316 deletions

File tree

cabotage/client/templates/user/application_releases.html

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,17 @@ <h2 class="dpl-history-title">
4646

4747
<div class="dpl-list">
4848
{% for release in releases %}
49-
{% set rel_img = release.image_object %}
50-
{% set rel_sha = release.commit_sha if release.commit_sha and release.commit_sha != 'null' else none %}
49+
{% set rel_img = image_by_id.get(release.image.get('id', '')|string) %}
50+
{% set rel_sha = (release.release_metadata or {}).get('sha') or (rel_img.image_metadata or {}).get('sha') if rel_img else (release.release_metadata or {}).get('sha') %}
51+
{% set rel_sha = rel_sha if rel_sha and rel_sha != 'null' else none %}
5152
{% set rel_ref = (release.release_metadata or {}).get('ref', '') %}
5253
{% if not rel_ref and rel_img %}
5354
{% set rel_ref = (rel_img.image_metadata or {}).get('ref', '') %}
5455
{% endif %}
5556
{% set rel_auto = release.release_metadata and release.release_metadata.get('auto_deploy') %}
5657
{% set is_latest = latest_built_release and release.id == latest_built_release.id %}
5758
{% set cfg_count = release.configuration|length if release.configuration else 0 %}
58-
{% set proc_count = release.processes|length if release.built and release.image_object else 0 %}
59+
{% set proc_count = release_proc_counts.get(release.id|string, 0) %}
5960

6061
<a href="{{ url_for('user.release_detail', org_slug=org_slug, project_slug=project_slug, app_slug=app_slug, release_id=release.id) }}"
6162
class="dpl-row {{ 'dpl-row-error' if release.error }}">

cabotage/server/query_helpers.py

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
"""Shared query helpers for batch-loading status sets and related objects.
2+
3+
These helpers extract duplicated N+1-avoidance patterns from view functions
4+
into reusable functions.
5+
"""
6+
7+
import uuid as _uuid
8+
9+
from sqlalchemy import and_, case, func, or_
10+
11+
from cabotage.server import db
12+
from cabotage.server.models.projects import (
13+
ApplicationEnvironment,
14+
Deployment,
15+
Image,
16+
Release,
17+
)
18+
19+
20+
def compute_app_status_sets(app_ids):
21+
"""Batch-compute deployed/errored/building status for a list of application IDs.
22+
23+
Queries via the default ApplicationEnvironment (k8s_identifier IS NULL).
24+
25+
Returns dict with keys: deployed_app_ids, errored_app_ids, building_app_ids
26+
"""
27+
deployed_app_ids = set()
28+
errored_app_ids = set()
29+
building_app_ids = set()
30+
31+
if not app_ids:
32+
return {
33+
"deployed_app_ids": deployed_app_ids,
34+
"errored_app_ids": errored_app_ids,
35+
"building_app_ids": building_app_ids,
36+
}
37+
38+
# Apps with any running or completed deployment
39+
deployed_app_ids = {
40+
row[0]
41+
for row in db.session.query(Deployment.application_id)
42+
.join(ApplicationEnvironment)
43+
.filter(
44+
Deployment.application_id.in_(app_ids),
45+
ApplicationEnvironment.k8s_identifier.is_(None),
46+
or_(
47+
Deployment.complete == True, # noqa: E712
48+
and_(
49+
Deployment.complete == False, # noqa: E712
50+
Deployment.error == False, # noqa: E712
51+
),
52+
),
53+
)
54+
.distinct()
55+
}
56+
57+
# Apps where latest error image version > latest built image version
58+
error_sub = (
59+
db.session.query(
60+
Image.application_id,
61+
func.max(Image.version).label("v"),
62+
)
63+
.join(ApplicationEnvironment)
64+
.filter(
65+
Image.application_id.in_(app_ids),
66+
ApplicationEnvironment.k8s_identifier.is_(None),
67+
Image.error == True, # noqa: E712
68+
)
69+
.group_by(Image.application_id)
70+
.subquery()
71+
)
72+
built_sub = (
73+
db.session.query(
74+
Image.application_id,
75+
func.max(Image.version).label("v"),
76+
)
77+
.join(ApplicationEnvironment)
78+
.filter(
79+
Image.application_id.in_(app_ids),
80+
ApplicationEnvironment.k8s_identifier.is_(None),
81+
Image.built == True, # noqa: E712
82+
)
83+
.group_by(Image.application_id)
84+
.subquery()
85+
)
86+
errored_app_ids = {
87+
row[0]
88+
for row in db.session.query(error_sub.c.application_id)
89+
.outerjoin(
90+
built_sub,
91+
error_sub.c.application_id == built_sub.c.application_id,
92+
)
93+
.filter(or_(built_sub.c.v.is_(None), error_sub.c.v > built_sub.c.v))
94+
}
95+
96+
# Apps with any in-progress image build
97+
building_app_ids = {
98+
row[0]
99+
for row in db.session.query(Image.application_id)
100+
.join(ApplicationEnvironment)
101+
.filter(
102+
Image.application_id.in_(app_ids),
103+
ApplicationEnvironment.k8s_identifier.is_(None),
104+
Image.built == False, # noqa: E712
105+
Image.error == False, # noqa: E712
106+
)
107+
.distinct()
108+
}
109+
110+
return {
111+
"deployed_app_ids": deployed_app_ids,
112+
"errored_app_ids": errored_app_ids,
113+
"building_app_ids": building_app_ids,
114+
}
115+
116+
117+
def compute_ae_status_sets(ae_ids):
118+
"""Batch-compute deployment/image status for a list of ApplicationEnvironment IDs.
119+
120+
Returns dict with keys: deploying_ae_ids, completed_ae_ids, running_ae_ids,
121+
building_ae_ids, errored_ae_ids, last_deploy_by_ae, deploy_count
122+
"""
123+
deploying_ae_ids = set()
124+
completed_ae_ids = set()
125+
running_ae_ids = set()
126+
building_ae_ids = set()
127+
errored_ae_ids = set()
128+
last_deploy_by_ae = {}
129+
deploy_count = 0
130+
131+
if not ae_ids:
132+
return {
133+
"deploying_ae_ids": deploying_ae_ids,
134+
"completed_ae_ids": completed_ae_ids,
135+
"running_ae_ids": running_ae_ids,
136+
"building_ae_ids": building_ae_ids,
137+
"errored_ae_ids": errored_ae_ids,
138+
"last_deploy_by_ae": last_deploy_by_ae,
139+
"deploy_count": deploy_count,
140+
}
141+
142+
# Latest deployment status per ae (for deploying/running)
143+
latest_deploy_created_sub = (
144+
db.session.query(
145+
Deployment.application_environment_id,
146+
func.max(Deployment.created).label("max_created"),
147+
)
148+
.filter(Deployment.application_environment_id.in_(ae_ids))
149+
.group_by(Deployment.application_environment_id)
150+
.subquery()
151+
)
152+
latest_deploys = (
153+
db.session.query(
154+
Deployment.application_environment_id,
155+
Deployment.complete,
156+
Deployment.error,
157+
)
158+
.join(
159+
latest_deploy_created_sub,
160+
and_(
161+
Deployment.application_environment_id
162+
== latest_deploy_created_sub.c.application_environment_id,
163+
Deployment.created == latest_deploy_created_sub.c.max_created,
164+
),
165+
)
166+
.all()
167+
)
168+
deploying_ae_ids = {r[0] for r in latest_deploys if not r[1] and not r[2]}
169+
running_ae_ids = {r[0] for r in latest_deploys if r[1] or not r[2]}
170+
171+
# Completed deploy stats: count + last deploy per ae
172+
deploy_stats = (
173+
db.session.query(
174+
Deployment.application_environment_id,
175+
func.count(Deployment.id),
176+
func.max(Deployment.created),
177+
)
178+
.filter(
179+
Deployment.application_environment_id.in_(ae_ids),
180+
Deployment.complete == True, # noqa: E712
181+
)
182+
.group_by(Deployment.application_environment_id)
183+
.all()
184+
)
185+
completed_ae_ids = {r[0] for r in deploy_stats}
186+
last_deploy_by_ae = {r[0]: r[2] for r in deploy_stats}
187+
deploy_count = sum(r[1] for r in deploy_stats)
188+
189+
# Image stats: one query for error, built, and building checks
190+
image_stats = (
191+
db.session.query(
192+
Image.application_environment_id,
193+
func.max(
194+
case((Image.error == True, Image.version), else_=None) # noqa: E712
195+
).label("max_error_v"),
196+
func.max(
197+
case((Image.built == True, Image.version), else_=None) # noqa: E712
198+
).label("max_built_v"),
199+
func.count(
200+
case(
201+
(
202+
and_(
203+
Image.built == False, # noqa: E712
204+
Image.error == False, # noqa: E712
205+
),
206+
1,
207+
)
208+
)
209+
).label("building_count"),
210+
)
211+
.filter(Image.application_environment_id.in_(ae_ids))
212+
.group_by(Image.application_environment_id)
213+
.all()
214+
)
215+
errored_ae_ids = {
216+
r[0]
217+
for r in image_stats
218+
if r[1] is not None and (r[2] is None or r[1] > r[2])
219+
}
220+
building_ae_ids = {r[0] for r in image_stats if r[3] > 0}
221+
222+
return {
223+
"deploying_ae_ids": deploying_ae_ids,
224+
"completed_ae_ids": completed_ae_ids,
225+
"running_ae_ids": running_ae_ids,
226+
"building_ae_ids": building_ae_ids,
227+
"errored_ae_ids": errored_ae_ids,
228+
"last_deploy_by_ae": last_deploy_by_ae,
229+
"deploy_count": deploy_count,
230+
}
231+
232+
233+
class RelatedObjectResolver:
234+
"""Caches Release/Image lookups from JSONB foreign keys.
235+
236+
Avoids cascading queries like deployment.release_object → release.image_object.
237+
"""
238+
239+
def __init__(self, images=None, releases=None):
240+
self._release_cache = {}
241+
self._image_cache = {i.id: i for i in (images or [])}
242+
self._all_releases = releases or []
243+
244+
def warm_caches(self, deployments, releases):
245+
"""Pre-resolve all Release/Image references from deployments and releases."""
246+
for d in deployments:
247+
self._get_release(d)
248+
for r in releases:
249+
self._get_image_for_release(r)
250+
251+
def build_lookup_dicts(self):
252+
"""Return (release_by_id, image_by_id) dicts keyed by string UUID."""
253+
release_by_id = {str(k): v for k, v in self._release_cache.items() if v}
254+
image_by_id = {str(k): v for k, v in self._image_cache.items() if v}
255+
return release_by_id, image_by_id
256+
257+
def get_release(self, deploy):
258+
"""Get the Release object referenced by a Deployment's JSONB field."""
259+
return self._get_release(deploy)
260+
261+
def get_image_for_release(self, rel):
262+
"""Get the Image object referenced by a Release's JSONB field."""
263+
return self._get_image_for_release(rel)
264+
265+
def _get_release(self, deploy):
266+
if not deploy or not deploy.release:
267+
return None
268+
rid = deploy.release.get("id")
269+
if not rid:
270+
return None
271+
rid = _uuid.UUID(rid) if isinstance(rid, str) else rid
272+
if rid not in self._release_cache:
273+
found = next((r for r in self._all_releases if r.id == rid), None)
274+
if found is None:
275+
found = Release.query.get(rid)
276+
self._release_cache[rid] = found
277+
return self._release_cache[rid]
278+
279+
def _get_image_for_release(self, rel):
280+
if not rel or not rel.image:
281+
return None
282+
iid = rel.image.get("id")
283+
if not iid:
284+
return None
285+
iid = _uuid.UUID(iid) if isinstance(iid, str) else iid
286+
if iid not in self._image_cache:
287+
self._image_cache[iid] = Image.query.get(iid)
288+
return self._image_cache[iid]
289+
290+
291+
def extract_latest_variants(images, releases, deployments):
292+
"""Extract latest_* variants from pre-fetched lists.
293+
294+
Returns dict with keys: latest_image, latest_image_built, latest_image_error,
295+
latest_image_building, latest_release, latest_release_built, latest_release_building,
296+
latest_deployment, latest_deployment_completed, has_releases
297+
"""
298+
return {
299+
"latest_image": images[0] if images else None,
300+
"latest_image_built": next((i for i in images if i.built), None),
301+
"latest_image_error": next((i for i in images if i.error), None),
302+
"latest_image_building": next(
303+
(i for i in images if not i.built and not i.error), None
304+
),
305+
"latest_release": releases[0] if releases else None,
306+
"latest_release_built": next((r for r in releases if r.built), None),
307+
"latest_release_building": next(
308+
(r for r in releases if not r.built and not r.error), None
309+
),
310+
"latest_deployment": deployments[0] if deployments else None,
311+
"latest_deployment_completed": next(
312+
(d for d in deployments if d.complete), None
313+
),
314+
"has_releases": len(releases) > 0,
315+
}
316+
317+
318+
def compute_process_counts(releases, resolver):
319+
"""Compute service process count per release (excludes release/postdeploy commands).
320+
321+
Returns {str(release_id): int}.
322+
"""
323+
release_proc_counts = {}
324+
for r in releases:
325+
img = resolver.get_image_for_release(r)
326+
if r.built and img and img.processes:
327+
release_proc_counts[str(r.id)] = sum(
328+
1
329+
for k in img.processes
330+
if not k.startswith("release")
331+
and not k.startswith("postdeploy")
332+
)
333+
return release_proc_counts
334+
335+
336+
def split_image_processes(image):
337+
"""Split image.processes into (service_procs, release_cmds, postdeploy_cmds).
338+
339+
Mirrors Release.processes / release_commands / postdeploy_commands without
340+
triggering an image_object query.
341+
"""
342+
if not image or not image.processes:
343+
return {}, {}, {}
344+
all_procs = image.processes
345+
service_procs = {
346+
k: v
347+
for k, v in all_procs.items()
348+
if not (k.startswith("release") or k.startswith("postdeploy"))
349+
}
350+
release_cmds = {
351+
k: v for k, v in all_procs.items() if k.startswith("release")
352+
}
353+
postdeploy_cmds = {
354+
k: v for k, v in all_procs.items() if k.startswith("postdeploy")
355+
}
356+
return service_procs, release_cmds, postdeploy_cmds

0 commit comments

Comments
 (0)