Summary
During operational work on a production deployment, we repeatedly observed a single database query fill Postgres's temp-file space and run for roughly two hours before finishing. The query is a SELECT DISTINCT over every column of the source-image ("captures") table, joined against project ownership/membership. On a deployment with tens of millions of source images, deduplicating the entire wide row forces Postgres to sort the whole result set instead of just checking for duplicate IDs, and that sort spills to disk. Based on code reading (see below), the .distinct() call responsible appears to live in a permission-scoping helper that every list and detail endpoint in the app calls on every request, not just captures — so on a large deployment this query re-fires constantly, competing for disk and I/O with everything else the database is doing.
Observed behavior
- A query matching the shape
SELECT DISTINCT main_sourceimage.* FROM main_sourceimage <joins to project ownership/membership> ORDER BY <capture ordering> was seen filling Postgres's temp_file usage and running for roughly two hours before completing, on a production deployment with a captures table in the tens of millions of rows.
- It appeared to re-fire whenever the application was in normal use, consistent with it living in a code path that runs on every (or nearly every) request rather than a one-off admin/export action.
- This was observed opportunistically during unrelated database maintenance, not via a dedicated profiling session — see "What we still need to verify" below.
Suspected root cause (based on code reading, not a captured EXPLAIN plan)
The project's permission-scoping queryset method looks like the source of this query shape:
# ami/base/models.py:42-88 (BaseQuerySet.visible_for_user)
def visible_for_user(self, user: User | AnonymousUser) -> QuerySet:
...
filter_condition = Q(**{f"{project_field}draft": False})
if not is_anonymous:
filter_condition |= Q(**{f"{project_field}owner": user}) | Q(**{f"{project_field}members": user})
return self.filter(filter_condition).distinct()
project__members traverses a many-to-many relationship (Project.members, ami/main/models.py:297-301), and combining it in an OR with the project__owner foreign-key condition is a plausible source of duplicate joined rows — the classic case where Django ORM code reaches for .distinct() defensively. The problem is that this .distinct() has no field list, so Django emits SELECT DISTINCT <every column> rather than deduplicating by primary key. On a table as wide as the captures table, with tens of millions of rows, that turns into a full sort of the entire result set.
This method is not specific to captures — it's called from the shared base viewset that (as far as we can tell from a repo-wide search) essentially every list/detail endpoint in the app inherits from:
# ami/main/api/views.py:134-141 (DefaultViewSet.get_queryset)
def get_queryset(self):
qs: QuerySet = super().get_queryset()
assert self.queryset is not None
if isinstance(qs, BaseQuerySet):
return qs.visible_for_user(self.request.user) # type: ignore
return qs
The captures viewset (SourceImageViewSet, ami/main/api/views.py:572) picks this up through super().get_queryset() at ami/main/api/views.py:625, then layers select_related(...), order_by("timestamp"), and further filters on top — none of which clear the .distinct() flag set earlier in the chain. The source-image model's default ordering (ami/main/models.py:2563-2564, Meta.ordering = ("deployment", "event", "timestamp")) means an ORDER BY is present even on call sites that don't explicitly set one, matching the ORDER BY seen in the observed query shape.
Because the captures table is by far the largest project-scoped table in the schema, and every visible-captures request runs through this shared method, it's the most likely candidate for the query we saw — but we have not yet captured a live EXPLAIN to confirm this is the exact call site versus some other place in the codebase with a similar shape.
A second, smaller instance of the same pattern exists in the ML pipeline result-saving path:
# ami/ml/models/pipeline.py:1049
source_images = SourceImage.objects.filter(pk__in=[int(img.id) for img in results.source_images]).distinct()
This runs inside the Celery task that saves each batch of ML pipeline results (save_results), which fires repeatedly while any processing job is running — consistent with the "re-fires whenever the app is online" observation, independent of the visible_for_user theory above. Here, though, filtering by pk__in on the primary key cannot itself produce duplicate rows, so .distinct() looks unnecessary rather than a defense against a join — it's possible this predates a de-duplication step done elsewhere, or defends against results.source_images itself containing duplicate IDs. Either way, combined with the model's default Meta.ordering, this call also produces a SELECT DISTINCT <full row> ... ORDER BY ... over however many rows are in the batch, and is worth cleaning up in the same pass even if it turns out to be a secondary contributor rather than the two-hour query itself.
Production impact (rough estimates, not measured precisely)
- Duration: roughly two hours per occurrence, observed more than once.
- Temp file usage: on the order of tens of gigabytes (our rough recollection is ~58 GB) spilled to disk for a single execution.
- Frequency: appeared to recur whenever the deployment was actively serving traffic, rather than being a rare one-off — consistent with a query embedded in a common request path rather than a scheduled job.
- Scale of the dataset where this was observed: a captures table in the tens of millions of rows.
These numbers come from operational observation during unrelated database work, not from a dedicated benchmark — treat them as order-of-magnitude, not precise.
Fix directions to discuss (ordered by effort/risk, lowest first)
-
Narrow the .distinct() in visible_for_user to the primary key instead of the full row. Django's .distinct(field) compiles to Postgres's DISTINCT ON, which requires that field to lead the ORDER BY — awkward here since callers reorder afterward (e.g. captures by timestamp). A safer equivalent: compute the deduplicated set of matching IDs in a subquery and filter the outer (unordered, unjoined) queryset against it, e.g. self.filter(pk__in=self.filter(filter_condition).values_list("pk", flat=True)). This moves the expensive sort onto a single narrow indexed column instead of the whole row, without touching the join logic. Because visible_for_user is the shared permission gate for effectively every project-scoped model, any change here needs a full test pass — correctness regressions in a permission filter are worse than the performance problem being fixed.
-
Replace the OR-across-relations with Exists() subqueries so no join multiplies rows in the first place, removing the need for .distinct() entirely — e.g. Q(project__draft=False) | Q(project__owner=user) | Q(project__in=Project.objects.filter(members=user)), or an Exists()-based membership check. This mirrors a pattern already used elsewhere in this exact viewset for the same reason (SourceImageViewSet.filter_by_has_detections, ami/main/api/views.py:702-715, uses models.Exists(...) specifically to avoid a duplicating join instead of filtering-then-.distinct()). Higher effort than option 1 because the anonymous-user branch and every model's project_accessor variant need to be re-verified for equivalence, but it addresses the pattern at its root rather than working around it.
-
Drop the .distinct() in the save_results call site (ami/ml/models/pipeline.py:1049) if we confirm results.source_images cannot contain duplicate IDs — filtering on pk__in alone doesn't need it. If duplicates are possible, dedup the Python list (set(...)) before building the queryset instead of asking Postgres to do it.
-
If 1 and 2 don't fully close the gap on the largest deployments, consider a covering index or a cached "visible project IDs" computation per request — a bigger lift, worth revisiting only after measuring the effect of the above.
What we still need to verify
- Capture a live
EXPLAIN (ANALYZE, BUFFERS) or an application trace (e.g. from our APM) that pins the exact endpoint/call site responsible for the two-hour query, to confirm it's visible_for_user (and specifically the captures/SourceImage path) rather than a different call site with a similar shape. This is pending — we don't yet have a captured trace tied to a specific request.
- Confirm whether the
project__owner / project__members OR in visible_for_user can actually produce duplicate rows for a real project/user combination (vs. being defensive), so we know whether removing or narrowing .distinct() is safe as-is or needs the Exists() rewrite.
- Confirm whether
results.source_images in the ML pipeline results payload can contain duplicate source-image IDs, to decide whether ami/ml/models/pipeline.py:1049's .distinct() can simply be deleted.
- Once a fix candidate exists, get before/after
EXPLAIN timing on a snapshot of a large production-sized dataset (tens of millions of source images) to confirm the fix actually removes the temp-file spill rather than just moving the cost elsewhere.
Drafted with Claude Code from operational observations during database maintenance; the root cause is from code reading and is pending an EXPLAIN/APM trace (see “What we still need to verify”).
Summary
During operational work on a production deployment, we repeatedly observed a single database query fill Postgres's temp-file space and run for roughly two hours before finishing. The query is a
SELECT DISTINCTover every column of the source-image ("captures") table, joined against project ownership/membership. On a deployment with tens of millions of source images, deduplicating the entire wide row forces Postgres to sort the whole result set instead of just checking for duplicate IDs, and that sort spills to disk. Based on code reading (see below), the.distinct()call responsible appears to live in a permission-scoping helper that every list and detail endpoint in the app calls on every request, not just captures — so on a large deployment this query re-fires constantly, competing for disk and I/O with everything else the database is doing.Observed behavior
SELECT DISTINCT main_sourceimage.* FROM main_sourceimage <joins to project ownership/membership> ORDER BY <capture ordering>was seen filling Postgres'stemp_fileusage and running for roughly two hours before completing, on a production deployment with a captures table in the tens of millions of rows.Suspected root cause (based on code reading, not a captured EXPLAIN plan)
The project's permission-scoping queryset method looks like the source of this query shape:
project__memberstraverses a many-to-many relationship (Project.members,ami/main/models.py:297-301), and combining it in anORwith theproject__ownerforeign-key condition is a plausible source of duplicate joined rows — the classic case where Django ORM code reaches for.distinct()defensively. The problem is that this.distinct()has no field list, so Django emitsSELECT DISTINCT <every column>rather than deduplicating by primary key. On a table as wide as the captures table, with tens of millions of rows, that turns into a full sort of the entire result set.This method is not specific to captures — it's called from the shared base viewset that (as far as we can tell from a repo-wide search) essentially every list/detail endpoint in the app inherits from:
The captures viewset (
SourceImageViewSet,ami/main/api/views.py:572) picks this up throughsuper().get_queryset()atami/main/api/views.py:625, then layersselect_related(...),order_by("timestamp"), and further filters on top — none of which clear the.distinct()flag set earlier in the chain. The source-image model's default ordering (ami/main/models.py:2563-2564,Meta.ordering = ("deployment", "event", "timestamp")) means anORDER BYis present even on call sites that don't explicitly set one, matching theORDER BYseen in the observed query shape.Because the captures table is by far the largest project-scoped table in the schema, and every visible-captures request runs through this shared method, it's the most likely candidate for the query we saw — but we have not yet captured a live
EXPLAINto confirm this is the exact call site versus some other place in the codebase with a similar shape.A second, smaller instance of the same pattern exists in the ML pipeline result-saving path:
This runs inside the Celery task that saves each batch of ML pipeline results (
save_results), which fires repeatedly while any processing job is running — consistent with the "re-fires whenever the app is online" observation, independent of thevisible_for_usertheory above. Here, though, filtering bypk__inon the primary key cannot itself produce duplicate rows, so.distinct()looks unnecessary rather than a defense against a join — it's possible this predates a de-duplication step done elsewhere, or defends againstresults.source_imagesitself containing duplicate IDs. Either way, combined with the model's defaultMeta.ordering, this call also produces aSELECT DISTINCT <full row> ... ORDER BY ...over however many rows are in the batch, and is worth cleaning up in the same pass even if it turns out to be a secondary contributor rather than the two-hour query itself.Production impact (rough estimates, not measured precisely)
These numbers come from operational observation during unrelated database work, not from a dedicated benchmark — treat them as order-of-magnitude, not precise.
Fix directions to discuss (ordered by effort/risk, lowest first)
Narrow the
.distinct()invisible_for_userto the primary key instead of the full row. Django's.distinct(field)compiles to Postgres'sDISTINCT ON, which requires that field to lead theORDER BY— awkward here since callers reorder afterward (e.g. captures bytimestamp). A safer equivalent: compute the deduplicated set of matching IDs in a subquery and filter the outer (unordered, unjoined) queryset against it, e.g.self.filter(pk__in=self.filter(filter_condition).values_list("pk", flat=True)). This moves the expensive sort onto a single narrow indexed column instead of the whole row, without touching the join logic. Becausevisible_for_useris the shared permission gate for effectively every project-scoped model, any change here needs a full test pass — correctness regressions in a permission filter are worse than the performance problem being fixed.Replace the OR-across-relations with
Exists()subqueries so no join multiplies rows in the first place, removing the need for.distinct()entirely — e.g.Q(project__draft=False) | Q(project__owner=user) | Q(project__in=Project.objects.filter(members=user)), or anExists()-based membership check. This mirrors a pattern already used elsewhere in this exact viewset for the same reason (SourceImageViewSet.filter_by_has_detections,ami/main/api/views.py:702-715, usesmodels.Exists(...)specifically to avoid a duplicating join instead of filtering-then-.distinct()). Higher effort than option 1 because the anonymous-user branch and every model'sproject_accessorvariant need to be re-verified for equivalence, but it addresses the pattern at its root rather than working around it.Drop the
.distinct()in thesave_resultscall site (ami/ml/models/pipeline.py:1049) if we confirmresults.source_imagescannot contain duplicate IDs — filtering onpk__inalone doesn't need it. If duplicates are possible, dedup the Python list (set(...)) before building the queryset instead of asking Postgres to do it.If 1 and 2 don't fully close the gap on the largest deployments, consider a covering index or a cached "visible project IDs" computation per request — a bigger lift, worth revisiting only after measuring the effect of the above.
What we still need to verify
EXPLAIN (ANALYZE, BUFFERS)or an application trace (e.g. from our APM) that pins the exact endpoint/call site responsible for the two-hour query, to confirm it'svisible_for_user(and specifically the captures/SourceImagepath) rather than a different call site with a similar shape. This is pending — we don't yet have a captured trace tied to a specific request.project__owner/project__membersORinvisible_for_usercan actually produce duplicate rows for a real project/user combination (vs. being defensive), so we know whether removing or narrowing.distinct()is safe as-is or needs theExists()rewrite.results.source_imagesin the ML pipeline results payload can contain duplicate source-image IDs, to decide whetherami/ml/models/pipeline.py:1049's.distinct()can simply be deleted.EXPLAINtiming on a snapshot of a large production-sized dataset (tens of millions of source images) to confirm the fix actually removes the temp-file spill rather than just moving the cost elsewhere.Drafted with Claude Code from operational observations during database maintenance; the root cause is from code reading and is pending an
EXPLAIN/APM trace (see “What we still need to verify”).