Skip to content

Commit 88dcbeb

Browse files
committed
communities: batch parent lookups to fix SearchCommunities/ListAllCommunities N+1
1 parent a98fedc commit 88dcbeb

5 files changed

Lines changed: 120 additions & 18 deletions

File tree

app/backend/src/couchers/db.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from alembic.config import Config
1313
from geoalchemy2 import WKBElement
1414
from opentelemetry import trace
15-
from sqlalchemy import Engine, Row, Subquery, create_engine, select, text, true
15+
from sqlalchemy import Engine, Subquery, create_engine, select, text, true
1616
from sqlalchemy.dialects import registry
1717
from sqlalchemy.orm.session import Session
1818
from sqlalchemy.pool import QueuePool
@@ -197,15 +197,59 @@ def _get_node_parents_recursive_cte_subquery(node_id: int) -> Subquery:
197197
).subquery()
198198

199199

200-
def get_node_parents_recursively(session: Session, node_id: int) -> Sequence[Row[tuple[int, int, int, Cluster]]]:
201-
subquery = _get_node_parents_recursive_cte_subquery(node_id)
202-
return session.execute(
203-
select(subquery, Cluster)
200+
def _get_nodes_parents_recursive_cte_subquery(node_ids: Sequence[int]) -> Subquery:
201+
parents = (
202+
select(
203+
Node.id.label("start_node_id"),
204+
Node.id,
205+
Node.parent_node_id,
206+
literal(0).label("level"),
207+
)
208+
.where(Node.id.in_(node_ids))
209+
.cte("parents", recursive=True)
210+
)
211+
212+
return select(
213+
parents.union(
214+
select(
215+
parents.c.start_node_id,
216+
Node.id,
217+
Node.parent_node_id,
218+
(parents.c.level + 1).label("level"),
219+
).join(parents, Node.id == parents.c.parent_node_id)
220+
)
221+
).subquery()
222+
223+
224+
def get_nodes_parents_recursively(
225+
session: Session, node_ids: Sequence[int]
226+
) -> dict[int, list[tuple[int, int, int, Cluster]]]:
227+
if not node_ids:
228+
return {}
229+
230+
subquery = _get_nodes_parents_recursive_cte_subquery(node_ids)
231+
rows = session.execute(
232+
select(
233+
subquery.c.start_node_id,
234+
subquery.c.id,
235+
subquery.c.parent_node_id,
236+
subquery.c.level,
237+
Cluster,
238+
)
204239
.join(Cluster, Cluster.parent_node_id == subquery.c.id)
205240
.where(Cluster.is_official_cluster)
206-
.order_by(subquery.c.level.desc())
241+
.order_by(subquery.c.start_node_id, subquery.c.level.desc())
207242
).all()
208243

244+
result: dict[int, list[tuple[int, int, int, Cluster]]] = {node_id: [] for node_id in node_ids}
245+
for start_node_id, id_, parent_node_id, level, cluster in rows:
246+
result[start_node_id].append((id_, parent_node_id, level, cluster))
247+
return result
248+
249+
250+
def get_node_parents_recursively(session: Session, node_id: int) -> Sequence[tuple[int, int, int, Cluster]]:
251+
return get_nodes_parents_recursively(session, [node_id])[node_id]
252+
209253

210254
def _can_moderate_any_cluster(session: Session, user_id: int, cluster_ids: list[int]) -> bool:
211255
query = select(

app/backend/src/couchers/servicers/communities.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
from couchers.constants import COMMUNITIES_SEARCH_FUZZY_SIMILARITY_THRESHOLD
1212
from couchers.context import CouchersContext
1313
from couchers.crypto import decrypt_page_token, encrypt_page_token
14-
from couchers.db import can_moderate_node, get_node_parents_recursively, is_user_in_node_geography
14+
from couchers.db import (
15+
can_moderate_node,
16+
get_node_parents_recursively,
17+
get_nodes_parents_recursively,
18+
is_user_in_node_geography,
19+
)
1520
from couchers.event_log import log_event
1621
from couchers.materialized_views import ClusterAdminCount, ClusterSubscriptionCount
1722
from couchers.models import (
@@ -50,8 +55,7 @@
5055
}
5156

5257

53-
def _parents_to_pb(session: Session, node_id: int) -> list[groups_pb2.Parent]:
54-
parents = get_node_parents_recursively(session, node_id)
58+
def _parents_list_to_pb(parents: Sequence[tuple[int, int, int, Cluster]]) -> list[groups_pb2.Parent]:
5559
return [
5660
groups_pb2.Parent(
5761
community=groups_pb2.CommunityParent(
@@ -65,16 +69,24 @@ def _parents_to_pb(session: Session, node_id: int) -> list[groups_pb2.Parent]:
6569
]
6670

6771

72+
def _parents_to_pb(session: Session, node_id: int) -> list[groups_pb2.Parent]:
73+
return _parents_list_to_pb(get_node_parents_recursively(session, node_id))
74+
75+
6876
def _community_summary_to_pb(
69-
session: Session, node: Node, cluster: Cluster, member_count: int | None, user_subscription: int | None
77+
node: Node,
78+
cluster: Cluster,
79+
member_count: int | None,
80+
user_subscription: int | None,
81+
parents: Sequence[tuple[int, int, int, Cluster]],
7082
) -> communities_pb2.CommunitySummary:
7183
return communities_pb2.CommunitySummary(
7284
community_id=node.id,
7385
name=cluster.name,
7486
slug=cluster.slug,
7587
member=user_subscription is not None,
7688
member_count=member_count or 1,
77-
parents=_parents_to_pb(session, node.id),
89+
parents=_parents_list_to_pb(parents),
7890
created=Timestamp_from_datetime(node.created),
7991
node_type=nodetype2api[node.node_type],
8092
)
@@ -207,9 +219,8 @@ def SearchCommunities(
207219
)
208220

209221
if not raw_query:
210-
query = query.order_by(Cluster.name.asc(), Node.id.asc())
222+
query = query.order_by(Cluster.name.asc(), Node.id.asc()).limit(page_size)
211223
elif len(raw_query) < 3:
212-
# < 3 chars is too short for a trigram match; prefix-match instead (still uses the trgm index)
213224
query = (
214225
query.where(func.immutable_unaccent(Cluster.name).ilike(func.unaccent(raw_query).concat("%")))
215226
.order_by(Cluster.name.asc(), Node.id.asc())
@@ -227,9 +238,11 @@ def SearchCommunities(
227238

228239
rows = session.execute(query).all()
229240

241+
parents_by_node_id = get_nodes_parents_recursively(session, [node.id for node, _, _, _ in rows])
242+
230243
return communities_pb2.SearchCommunitiesRes(
231244
results=[
232-
_community_summary_to_pb(session, node, cluster, member_count, user_subscription)
245+
_community_summary_to_pb(node, cluster, member_count, user_subscription, parents_by_node_id[node.id])
233246
for node, cluster, member_count, user_subscription in rows
234247
]
235248
)
@@ -659,9 +672,11 @@ def ListAllCommunities(
659672
.order_by(Node.id)
660673
).all()
661674

675+
parents_by_node_id = get_nodes_parents_recursively(session, [node.id for node, _, _, _ in results])
676+
662677
return communities_pb2.ListAllCommunitiesRes(
663678
communities=[
664-
_community_summary_to_pb(session, node, cluster, member_count, user_subscription)
679+
_community_summary_to_pb(node, cluster, member_count, user_subscription, parents_by_node_id[node.id])
665680
for node, cluster, member_count, user_subscription in results
666681
],
667682
)

app/backend/src/tests/test_communities.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
from sqlalchemy import select
88
from sqlalchemy.orm import Session
99

10-
from couchers.db import is_user_in_node_geography, session_scope
10+
from couchers.db import (
11+
get_node_parents_recursively,
12+
get_nodes_parents_recursively,
13+
is_user_in_node_geography,
14+
session_scope,
15+
)
1116
from couchers.helpers.clusters import CHILD_NODE_TYPE
1217
from couchers.materialized_views import refresh_materialized_views
1318
from couchers.models import (
@@ -1068,6 +1073,46 @@ def test_ListAllCommunities(testing_communities):
10681073
global_community = next(c for c in res.communities if c.community_id == w_id)
10691074
assert global_community.member # user6 is a member
10701075

1076+
@staticmethod
1077+
def test_get_nodes_parents_recursively_matches_single_id(testing_communities):
1078+
"""
1079+
get_nodes_parents_recursively (the batched CTE used by SearchCommunities/ListAllCommunities to
1080+
avoid an N+1) must return, for every node_id, exactly what get_node_parents_recursively(session,
1081+
node_id) returns for that node_id individually: same length, same order, same (id, parent_node_id,
1082+
level, cluster) contents.
1083+
"""
1084+
with session_scope() as session:
1085+
w_id = get_community_id(session, "Global")
1086+
c1_id = get_community_id(session, "Country 1")
1087+
c1r1_id = get_community_id(session, "Country 1, Region 1")
1088+
c1r1c1_id = get_community_id(session, "Country 1, Region 1, City 1")
1089+
c2r1c1_id = get_community_id(session, "Country 2, Region 1, City 1")
1090+
nonexistent_id = -1
1091+
1092+
node_ids = [w_id, c1_id, c1r1_id, c1r1c1_id, c2r1c1_id, nonexistent_id]
1093+
batched = get_nodes_parents_recursively(session, node_ids)
1094+
1095+
assert set(batched.keys()) == set(node_ids)
1096+
1097+
for node_id in node_ids:
1098+
expected = get_node_parents_recursively(session, node_id)
1099+
actual = batched[node_id]
1100+
assert len(actual) == len(expected)
1101+
for (a_id, a_parent_id, a_level, a_cluster), (e_id, e_parent_id, e_level, e_cluster) in zip(
1102+
actual, expected
1103+
):
1104+
assert a_id == e_id
1105+
assert a_parent_id == e_parent_id
1106+
assert a_level == e_level
1107+
assert a_cluster.id == e_cluster.id
1108+
assert a_cluster.name == e_cluster.name
1109+
1110+
assert [row[0] for row in batched[w_id]] == [w_id]
1111+
1112+
assert [row[0] for row in batched[c1r1c1_id]] == [w_id, c1_id, c1r1_id, c1r1c1_id]
1113+
1114+
assert batched[nonexistent_id] == []
1115+
10711116
@staticmethod
10721117
def test_ListRecentCommunities(testing_communities, monkeypatch):
10731118
"""

app/web/features/communities/CommunitiesPage/CommunitySearch.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ jest.mock("next/router", () => ({
2121
}),
2222
}));
2323

24-
// AuthProvider pulls in @sentry/nextjs, which crashes under jsdom
2524
jest.mock("platform/sentry", () => {
2625
const mockCaptureException = jest.fn();
2726
return {

app/web/features/communities/CommunitiesPage/CommunitySearch.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ export default function CommunitySearch() {
9090
onInputChange={handleInputChange}
9191
onChange={handleChange}
9292
getOptionLabel={(option) => option.name}
93-
// keep backend order; don't let MUI re-filter
9493
filterOptions={(x) => x}
9594
renderOption={(props, option) => {
9695
const { key, ...optionProps } = props;

0 commit comments

Comments
 (0)