Skip to content

Commit 655ace0

Browse files
committed
Add safe_in() utility to prevent PostgreSQL 65K parameter limit errors
PostgreSQL's wire protocol limits bind parameters to 65,535 per statement. When Django ORM's filter(field__in=python_list) generates WHERE field IN ($1, $2, ..., $65536+), it exceeds this limit when using server-side cursors (.iterator()). This introduces a safe_in() utility that uses a custom Django lookup (= ANY(%s)) for large lists, passing the entire list as a single PostgreSQL array parameter regardless of size. For small lists, the standard __in lookup is used unchanged. Applied safe_in() to all vulnerable code paths in pulpcore: - RepositoryVersion.get_content(), added(), removed() - import_repository_version() content mapping Also updated the test to use .iterator() so it reliably exercises the server-side cursor path that triggers the parameter limit. Assisted-By: claude-opus-4.6
1 parent dee04db commit 655ace0

6 files changed

Lines changed: 63 additions & 24 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Avoid exceeding PostgreSQL's 65,535 query parameter limit when filtering by large lists of IDs. This fixes `OperationalError` crashes during large import and copy operations involving more than 65,535 content units.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `safe_in()` to the plugin API for building `Q` objects that are safe for arbitrarily large value lists, avoiding PostgreSQL's 65,535 query parameter limit.

pulpcore/app/models/repository.py

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
get_prn,
2626
get_view_name_for_model,
2727
reverse,
28+
safe_in,
2829
)
2930
from pulpcore.cache import Cache
3031
from pulpcore.constants import ALL_KNOWN_CONTENT_CHECKSUMS, PROTECTED_REPO_VERSION_MESSAGE
@@ -981,7 +982,7 @@ def get_content(self, content_qs=None):
981982
Args:
982983
content_qs (django.db.models.QuerySet): The queryset for Content that will be
983984
restricted further to the content present in this repository version. If not given,
984-
``Content.objects.all()`` is used (to return over all content types present in the
985+
`Content.objects.all()` is used (to return over all content types present in the
985986
repository version).
986987
987988
Returns:
@@ -997,15 +998,7 @@ def get_content(self, content_qs=None):
997998
if content_qs is None:
998999
content_qs = Content.objects
9991000

1000-
content_ids = self.content_ids
1001-
if len(content_ids) >= 65535:
1002-
# Workaround for PostgreSQL's limit on the number of parameters in a query
1003-
content_ids = (
1004-
RepositoryVersion.objects.filter(pk=self.pk)
1005-
.annotate(cids=Func(F("content_ids"), function="unnest"))
1006-
.values_list("cids", flat=True)
1007-
)
1008-
return content_qs.filter(pk__in=content_ids)
1001+
return content_qs.filter(safe_in("pk", self.content_ids))
10091002

10101003
@property
10111004
def content(self):
@@ -1049,14 +1042,14 @@ def content_batch_qs(self, content_qs=None, order_by_params=("pk",), batch_size=
10491042
Args:
10501043
content_qs (django.db.models.QuerySet) The queryset for Content that will be
10511044
restricted further to the content present in this repository version. If not given,
1052-
``Content.objects.all()`` is used (to iterate over all content present in the
1045+
`Content.objects.all()` is used (to iterate over all content present in the
10531046
repository version). A plugin may want to use a specific subclass of
1054-
[pulpcore.plugin.models.Content][] or use e.g. ``filter()`` to select
1047+
[pulpcore.plugin.models.Content][] or use e.g. `filter()` to select
10551048
a subset of the repository version's content.
1056-
order_by_params (tuple of str): The parameters for the ``order_by`` clause
1057-
for the content. The Default is ``("pk",)``. This needs to
1049+
order_by_params (tuple of str): The parameters for the `order_by` clause
1050+
for the content. The Default is `("pk",)`. This needs to
10581051
specify a stable order. For example, if you want to iterate by
1059-
decreasing creation time stamps use ``("-pulp_created", "pk")`` to
1052+
decreasing creation time stamps use `("-pulp_created", "pk")` to
10601053
ensure that content records are still sorted by primary key even
10611054
if their creation timestamp happens to be equal.
10621055
batch_size (int): The maximum batch size.
@@ -1065,8 +1058,8 @@ def content_batch_qs(self, content_qs=None, order_by_params=("pk",), batch_size=
10651058
[django.db.models.QuerySet][]: A QuerySet representing a slice of the content.
10661059
10671060
Example:
1068-
The following code could be used to loop over all ``FileContent`` in
1069-
``repository_version``. It prefetches the related
1061+
The following code could be used to loop over all `FileContent` in
1062+
`repository_version`. It prefetches the related
10701063
[pulpcore.plugin.models.ContentArtifact][] instances for every batch::
10711064
10721065
repository_version = ...
@@ -1119,8 +1112,8 @@ def added(self, base_version=None):
11191112
if not base_version:
11201113
return Content.objects.filter(version_memberships__version_added=self)
11211114

1122-
return Content.objects.filter(pk__in=self.content_ids).exclude(
1123-
pk__in=base_version.content_ids
1115+
return Content.objects.filter(safe_in("pk", self.content_ids)).exclude(
1116+
safe_in("pk", base_version.content_ids)
11241117
)
11251118

11261119
def removed(self, base_version=None):
@@ -1134,8 +1127,8 @@ def removed(self, base_version=None):
11341127
if not base_version:
11351128
return Content.objects.filter(version_memberships__version_removed=self)
11361129

1137-
return Content.objects.filter(pk__in=base_version.content_ids).exclude(
1138-
pk__in=self.content_ids
1130+
return Content.objects.filter(safe_in("pk", base_version.content_ids)).exclude(
1131+
safe_in("pk", self.content_ids)
11391132
)
11401133

11411134
def contains(self, content):

pulpcore/app/tasks/importer.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
compute_file_hash,
4040
get_domain,
4141
get_domain_pk,
42+
safe_in,
4243
)
4344
from pulpcore.constants import TASK_STATES
4445
from pulpcore.exceptions.plugin import MissingPlugin
@@ -417,14 +418,14 @@ def import_repository_version(
417418
for repo_name, content_ids in mapping.items():
418419
repo_name = _get_destination_repo_name(importer, repo_name)
419420
dest_repo = Repository.objects.get(name=repo_name)
420-
content = Content.objects.filter(upstream_id__in=content_ids)
421+
content = Content.objects.filter(safe_in("upstream_id", content_ids))
421422
content_count += len(content_ids)
422423
with dest_repo.new_version() as new_version:
423424
new_version.set_content(content)
424425
else:
425426
# just map all the content to our destination repo
426427
dest_repo = Repository.objects.get(pk=dest_repo_pk)
427-
content = Content.objects.filter(pk__in=resulting_content_ids)
428+
content = Content.objects.filter(safe_in("pk", resulting_content_ids))
428429
content_count += len(resulting_content_ids)
429430
with dest_repo.new_version() as new_version:
430431
new_version.set_content(content)

pulpcore/app/util.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from django.apps import apps
1717
from django.conf import settings
1818
from django.db import connection
19-
from django.db.models import Model, UUIDField
19+
from django.db.models import Field, Lookup, Model, Q, UUIDField
2020
from rest_framework.reverse import reverse as drf_reverse
2121
from rest_framework.serializers import ValidationError
2222

@@ -26,6 +26,47 @@
2626
from pulpcore.app.loggers import deprecation_logger
2727
from pulpcore.exceptions.validation import InvalidSignatureError
2828

29+
POSTGRES_MAX_QUERY_PARAMS = 65535
30+
31+
32+
class AnyArray(Lookup):
33+
"""PostgreSQL `= ANY(%s)` lookup that passes a list as a single array parameter.
34+
35+
psycopg3 adapts the Python list into a PostgreSQL array, so the entire list
36+
counts as **one** bind parameter regardless of size. This avoids the
37+
protocol-level 65535-parameter limit that `IN ($1, $2, …)` hits.
38+
"""
39+
40+
lookup_name = "any_array"
41+
42+
def get_prep_lookup(self):
43+
return [self.lhs.output_field.get_prep_value(v) for v in self.rhs]
44+
45+
def as_sql(self, compiler, connection):
46+
lhs, lhs_params = self.process_lhs(compiler, connection)
47+
return f"{lhs} = ANY(%s)", lhs_params + [list(self.rhs)]
48+
49+
50+
Field.register_lookup(AnyArray)
51+
52+
53+
def safe_in(field_name, values):
54+
"""Build a `Q` object for `field__in` that is safe for arbitrarily large lists.
55+
56+
* If *values* is already a queryset (or other non-collection type), the
57+
normal `__in` lookup is used — Django turns it into a subquery.
58+
* If the collection has fewer than 65 535 items, `__in` is used as-is.
59+
* Otherwise `__any_array` is used so the whole list travels as a single
60+
PostgreSQL array parameter.
61+
"""
62+
if not isinstance(values, (list, set, tuple, frozenset)):
63+
return Q(**{f"{field_name}__in": values})
64+
values = list(values)
65+
if len(values) < POSTGRES_MAX_QUERY_PARAMS:
66+
return Q(**{f"{field_name}__in": values})
67+
return Q(**{f"{field_name}__any_array": values})
68+
69+
2970
# a little cache so viewset_for_model doesn't have to iterate over every app every time
3071
_model_viewset_cache = {}
3172

pulpcore/plugin/util.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
raise_for_unknown_content_units,
2828
resolve_prn,
2929
reverse,
30+
safe_in,
3031
set_current_user,
3132
set_domain,
3233
)
@@ -59,5 +60,6 @@
5960
"reverse",
6061
"set_current_user",
6162
"resolve_prn",
63+
"safe_in",
6264
"cache_key",
6365
]

0 commit comments

Comments
 (0)