Skip to content

Commit d2471e1

Browse files
authored
Merge pull request #10 from bluedynamics/fix/thumbor-auth-use-storage-connection
fix: @thumbor-auth prefers storage conn over psycopg pool (related to #8)
2 parents a34a2a4 + 9d64f21 commit d2471e1

3 files changed

Lines changed: 91 additions & 7 deletions

File tree

‎CHANGES.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@
1414
no catalog-lag skew vs. live workflow state.
1515
Closes [#8](https://github.com/bluedynamics/plone-pgthumbor/issues/8).
1616

17+
- Fix: `@thumbor-auth` REST service now prefers the ZODB storage
18+
connection (already held for the request) over the psycopg pool, so
19+
per-image auth verification doesn't contend on `pool.getconn()`.
20+
The SQL query is unchanged — this is strictly a connection-acquisition
21+
change, matching the pattern plone-pgcatalog uses in
22+
`_get_pg_read_connection`. Falls back to the pool when no ZODB
23+
storage is in scope (tests, scripts).
24+
Related to [#8](https://github.com/bluedynamics/plone-pgthumbor/issues/8).
25+
1726
## 0.6.3 (2026-04-13)
1827

1928
- Move `@@images` put of overrides, it is on a layer.

‎src/plone/pgthumbor/restapi.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from AccessControl import getSecurityManager
2424
from plone.pgcatalog.pool import get_pool
2525
from plone.pgcatalog.pool import get_request_connection
26+
from plone.pgcatalog.pool import get_storage_connection
2627
from plone.rest.service import Service
2728
from Products.CMFCore.utils import getToolByName
2829

@@ -66,9 +67,14 @@ def render(self):
6667
# Single PG query: does the object's allowed_roles overlap with
6768
# user principals? plone-pgcatalog stores allowedRolesAndUsers in
6869
# a dedicated TEXT[] column (with GIN index), not inside idx JSONB.
70+
#
71+
# Prefer the ZODB storage connection (already held for this request)
72+
# so we don't contend on the psycopg pool — under cold-cache load,
73+
# the pool becomes the bottleneck (see #8).
6974
try:
70-
pool = get_pool(self.context)
71-
conn = get_request_connection(pool)
75+
conn = get_storage_connection(self.context)
76+
if conn is None:
77+
conn = get_request_connection(get_pool(self.context))
7278
row = conn.execute(
7379
"SELECT (allowed_roles && %s::text[]) AS allowed "
7480
"FROM object_state WHERE zoid = %s",

‎tests/test_auth_service.py‎

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,14 @@ def _mock_catalog(self, principals=None):
3333
catalog._listAllowedRolesAndUsers.return_value = principals
3434
return catalog
3535

36-
def _patch_dependencies(self, service, catalog, row):
36+
def _patch_dependencies(self, service, catalog, row, storage_conn=True):
37+
"""Patch REST service dependencies.
38+
39+
By default (``storage_conn=True``) the storage connection is used
40+
— matching the production path where ZODB holds a request-scoped
41+
connection. Set ``storage_conn=False`` to simulate the pool
42+
fallback (tests, scripts, non-ZODB contexts).
43+
"""
3744
mock_conn = MagicMock()
3845
mock_conn.execute.return_value.fetchone.return_value = row
3946
mock_pool = MagicMock()
@@ -44,6 +51,10 @@ def _patch_dependencies(self, service, catalog, row):
4451
patch(
4552
"plone.pgthumbor.restapi.get_request_connection", return_value=mock_conn
4653
),
54+
patch(
55+
"plone.pgthumbor.restapi.get_storage_connection",
56+
return_value=mock_conn if storage_conn else None,
57+
),
4758
]
4859
return patches
4960

@@ -53,7 +64,7 @@ def test_allowed_user_returns_200(self):
5364
catalog = self._mock_catalog(["user:john", "Authenticated", "Anonymous"])
5465
patches = self._patch_dependencies(service, catalog, {"allowed": True})
5566

56-
with patches[0], patches[1] as mock_sm, patches[2], patches[3]:
67+
with patches[0], patches[1] as mock_sm, patches[2], patches[3], patches[4]:
5768
mock_sm.return_value.getUser.return_value = MagicMock()
5869
result = service.render()
5970

@@ -66,7 +77,7 @@ def test_denied_user_returns_401(self):
6677
catalog = self._mock_catalog(["user:john", "Authenticated"])
6778
patches = self._patch_dependencies(service, catalog, {"allowed": False})
6879

69-
with patches[0], patches[1] as mock_sm, patches[2], patches[3]:
80+
with patches[0], patches[1] as mock_sm, patches[2], patches[3], patches[4]:
7081
mock_sm.return_value.getUser.return_value = MagicMock()
7182
result = service.render()
7283

@@ -95,7 +106,7 @@ def test_zoid_not_in_catalog_returns_404(self):
95106
catalog = self._mock_catalog(["user:john", "Authenticated"])
96107
patches = self._patch_dependencies(service, catalog, None)
97108

98-
with patches[0], patches[1] as mock_sm, patches[2], patches[3]:
109+
with patches[0], patches[1] as mock_sm, patches[2], patches[3], patches[4]:
99110
mock_sm.return_value.getUser.return_value = MagicMock()
100111
result = service.render()
101112

@@ -110,10 +121,68 @@ def test_db_error_returns_503(self):
110121
with (
111122
patch("plone.pgthumbor.restapi.getToolByName", return_value=catalog),
112123
patch("plone.pgthumbor.restapi.getSecurityManager") as mock_sm,
113-
patch("plone.pgthumbor.restapi.get_pool", side_effect=Exception("DB down")),
124+
patch(
125+
"plone.pgthumbor.restapi.get_storage_connection",
126+
side_effect=Exception("DB down"),
127+
),
114128
):
115129
mock_sm.return_value.getUser.return_value = MagicMock()
116130
result = service.render()
117131

118132
assert service.request.response.status == 503
119133
assert "error" in json.loads(result)
134+
135+
def test_prefers_storage_connection_over_pool(self):
136+
"""Storage conn is used when available; pool is not touched (regression for #8)."""
137+
service = self._make_service("000000000000001a")
138+
catalog = self._mock_catalog(["user:john", "Authenticated", "Anonymous"])
139+
140+
storage_conn = MagicMock()
141+
storage_conn.execute.return_value.fetchone.return_value = {"allowed": True}
142+
143+
with (
144+
patch("plone.pgthumbor.restapi.getToolByName", return_value=catalog),
145+
patch("plone.pgthumbor.restapi.getSecurityManager") as mock_sm,
146+
patch(
147+
"plone.pgthumbor.restapi.get_storage_connection",
148+
return_value=storage_conn,
149+
) as mock_storage,
150+
patch("plone.pgthumbor.restapi.get_pool") as mock_pool,
151+
patch("plone.pgthumbor.restapi.get_request_connection") as mock_req_conn,
152+
):
153+
mock_sm.return_value.getUser.return_value = MagicMock()
154+
service.render()
155+
156+
mock_storage.assert_called_once_with(service.context)
157+
# Pool fallback must not be entered when storage conn is available.
158+
mock_pool.assert_not_called()
159+
mock_req_conn.assert_not_called()
160+
storage_conn.execute.assert_called_once()
161+
162+
def test_falls_back_to_pool_when_no_storage_connection(self):
163+
"""When storage conn is unavailable (tests, scripts), fall back to pool."""
164+
service = self._make_service("000000000000001a")
165+
catalog = self._mock_catalog(["user:john", "Authenticated", "Anonymous"])
166+
167+
pool_conn = MagicMock()
168+
pool_conn.execute.return_value.fetchone.return_value = {"allowed": True}
169+
170+
with (
171+
patch("plone.pgthumbor.restapi.getToolByName", return_value=catalog),
172+
patch("plone.pgthumbor.restapi.getSecurityManager") as mock_sm,
173+
patch(
174+
"plone.pgthumbor.restapi.get_storage_connection",
175+
return_value=None,
176+
),
177+
patch("plone.pgthumbor.restapi.get_pool") as mock_pool,
178+
patch(
179+
"plone.pgthumbor.restapi.get_request_connection",
180+
return_value=pool_conn,
181+
) as mock_req_conn,
182+
):
183+
mock_sm.return_value.getUser.return_value = MagicMock()
184+
service.render()
185+
186+
mock_pool.assert_called_once_with(service.context)
187+
mock_req_conn.assert_called_once()
188+
pool_conn.execute.assert_called_once()

0 commit comments

Comments
 (0)