Skip to content

Commit 98074bd

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 34f8c46 commit 98074bd

7 files changed

Lines changed: 66 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
@@ -988,7 +989,7 @@ def get_content(self, content_qs=None):
988989
Args:
989990
content_qs (django.db.models.QuerySet): The queryset for Content that will be
990991
restricted further to the content present in this repository version. If not given,
991-
``Content.objects.all()`` is used (to return over all content types present in the
992+
`Content.objects.all()` is used (to return over all content types present in the
992993
repository version).
993994
994995
Returns:
@@ -1004,15 +1005,7 @@ def get_content(self, content_qs=None):
10041005
if content_qs is None:
10051006
content_qs = Content.objects
10061007

1007-
content_ids = self.content_ids
1008-
if len(content_ids) >= 65535:
1009-
# Workaround for PostgreSQL's limit on the number of parameters in a query
1010-
content_ids = (
1011-
RepositoryVersion.objects.filter(pk=self.pk)
1012-
.annotate(cids=Func(F("content_ids"), function="unnest"))
1013-
.values_list("cids", flat=True)
1014-
)
1015-
return content_qs.filter(pk__in=content_ids)
1008+
return content_qs.filter(safe_in("pk", self.content_ids))
10161009

10171010
@property
10181011
def content(self):
@@ -1056,14 +1049,14 @@ def content_batch_qs(self, content_qs=None, order_by_params=("pk",), batch_size=
10561049
Args:
10571050
content_qs (django.db.models.QuerySet) The queryset for Content that will be
10581051
restricted further to the content present in this repository version. If not given,
1059-
``Content.objects.all()`` is used (to iterate over all content present in the
1052+
`Content.objects.all()` is used (to iterate over all content present in the
10601053
repository version). A plugin may want to use a specific subclass of
1061-
[pulpcore.plugin.models.Content][] or use e.g. ``filter()`` to select
1054+
[pulpcore.plugin.models.Content][] or use e.g. `filter()` to select
10621055
a subset of the repository version's content.
1063-
order_by_params (tuple of str): The parameters for the ``order_by`` clause
1064-
for the content. The Default is ``("pk",)``. This needs to
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
10651058
specify a stable order. For example, if you want to iterate by
1066-
decreasing creation time stamps use ``("-pulp_created", "pk")`` to
1059+
decreasing creation time stamps use `("-pulp_created", "pk")` to
10671060
ensure that content records are still sorted by primary key even
10681061
if their creation timestamp happens to be equal.
10691062
batch_size (int): The maximum batch size.
@@ -1072,8 +1065,8 @@ def content_batch_qs(self, content_qs=None, order_by_params=("pk",), batch_size=
10721065
[django.db.models.QuerySet][]: A QuerySet representing a slice of the content.
10731066
10741067
Example:
1075-
The following code could be used to loop over all ``FileContent`` in
1076-
``repository_version``. It prefetches the related
1068+
The following code could be used to loop over all `FileContent` in
1069+
`repository_version`. It prefetches the related
10771070
[pulpcore.plugin.models.ContentArtifact][] instances for every batch::
10781071
10791072
repository_version = ...
@@ -1126,8 +1119,8 @@ def added(self, base_version=None):
11261119
if not base_version:
11271120
return Content.objects.filter(version_memberships__version_added=self)
11281121

1129-
return Content.objects.filter(pk__in=self.content_ids).exclude(
1130-
pk__in=base_version.content_ids
1122+
return Content.objects.filter(safe_in("pk", self.content_ids)).exclude(
1123+
safe_in("pk", base_version.content_ids)
11311124
)
11321125

11331126
def removed(self, base_version=None):
@@ -1141,8 +1134,8 @@ def removed(self, base_version=None):
11411134
if not base_version:
11421135
return Content.objects.filter(version_memberships__version_removed=self)
11431136

1144-
return Content.objects.filter(pk__in=base_version.content_ids).exclude(
1145-
pk__in=self.content_ids
1137+
return Content.objects.filter(safe_in("pk", base_version.content_ids)).exclude(
1138+
safe_in("pk", self.content_ids)
11461139
)
11471140

11481141
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
@@ -410,14 +411,14 @@ def import_repository_version(
410411
for repo_name, content_ids in mapping.items():
411412
repo_name = _get_destination_repo_name(importer, repo_name)
412413
dest_repo = Repository.objects.get(name=repo_name)
413-
content = Content.objects.filter(upstream_id__in=content_ids)
414+
content = Content.objects.filter(safe_in("upstream_id", content_ids))
414415
content_count += len(content_ids)
415416
with dest_repo.new_version() as new_version:
416417
new_version.set_content(content)
417418
else:
418419
# just map all the content to our destination repo
419420
dest_repo = Repository.objects.get(pk=dest_repo_pk)
420-
content = Content.objects.filter(pk__in=resulting_content_ids)
421+
content = Content.objects.filter(safe_in("pk", resulting_content_ids))
421422
content_count += len(resulting_content_ids)
422423
with dest_repo.new_version() as new_version:
423424
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
]

pulpcore/tests/unit/models/test_repository.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,9 @@ def test_postgresql_parameter_limit(db, repository):
798798
PostgreSQL limits queries to 65535 parameters. This test verifies that content, added(),
799799
and removed() all handle >65535 items correctly.
800800
801+
The safe_in() utility (used internally) avoids this limit by using ``= ANY(%s)``
802+
(a single array parameter) for large value lists.
803+
801804
Queries MUST be evaluated via .iterator() because psycopg3 uses client-side binding for
802805
regular queries (inlining params into the SQL string, bypassing the limit) but server-side
803806
binding for server-side cursors (.iterator()), which enforces the 65,535 parameter cap.

0 commit comments

Comments
 (0)