Skip to content

Commit a035ec0

Browse files
Optimize database queries for friends balance summary and enrich member data (#211)
* feat: optimize database queries for friends balance summary and enrich member data * feat: optimize settlement aggregation for friends' balance summary * Update backend/app/expenses/service.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update backend/app/groups/service.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: enhance expense service with timezone-aware datetime and improve balance retrieval * feat: add tests for optimized member enrichment function in group service * feat: add tests for handling zero and negative balance scenarios in friends' balance summary * feat: enhance tests for member enrichment function with improved coverage and logging --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent c8a6d92 commit a035ec0

File tree

6 files changed

+1320
-214
lines changed

6 files changed

+1320
-214
lines changed

backend/app/expenses/service.py

Lines changed: 168 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections import defaultdict
2-
from datetime import datetime, timedelta
2+
from datetime import datetime, timedelta, timezone
33
from typing import Any, Dict, List, Optional
44

55
from app.config import logger
@@ -953,141 +953,203 @@ async def get_user_balance_in_group(
953953
}
954954

955955
async def get_friends_balance_summary(self, user_id: str) -> Dict[str, Any]:
956-
"""Get cross-group friend balances for a user"""
956+
"""
957+
Get cross-group friend balances using optimized aggregation pipeline.
957958
958-
# Get all groups user belongs to
959+
Performance: Optimized to use single aggregation query instead of N×M queries.
960+
Example: 20 friends × 5 groups = 3 queries total (vs 100+ with naive approach).
961+
962+
Uses MongoDB aggregation to calculate all balances at once, then batch enriches
963+
with user and group details for optimal performance.
964+
"""
965+
966+
# First, get all groups user belongs to (need this to filter friends properly)
959967
groups = await self.groups_collection.find({"members.userId": user_id}).to_list(
960-
None
968+
length=500
961969
)
962970

963-
friends_balance = []
964-
user_totals = {"totalOwedToYou": 0, "totalYouOwe": 0}
971+
if not groups:
972+
return {
973+
"friendsBalance": [],
974+
"summary": {
975+
"totalOwedToYou": 0,
976+
"totalYouOwe": 0,
977+
"netBalance": 0,
978+
"friendCount": 0,
979+
"activeGroups": 0,
980+
},
981+
}
965982

966-
# Get all unique friends across groups
967-
friend_ids = set()
983+
# Extract group IDs and friend IDs (only from user's groups)
984+
group_ids = [str(g["_id"]) for g in groups]
985+
friend_ids_in_groups = set()
968986
for group in groups:
969987
for member in group["members"]:
970988
if member["userId"] != user_id:
971-
friend_ids.add(member["userId"])
989+
friend_ids_in_groups.add(member["userId"])
972990

973-
# Get user names & images
974-
users = await self.users_collection.find(
975-
{"_id": {"$in": [ObjectId(uid) for uid in friend_ids]}}
976-
).to_list(None)
977-
user_names = {str(user["_id"]): user.get("name", "Unknown") for user in users}
978-
user_images = {str(user["_id"]): user.get("imageUrl") for user in users}
991+
# OPTIMIZATION: Single aggregation to calculate all friend balances at once
992+
# Only for friends in user's groups and groups user belongs to
993+
pipeline = [
994+
# Step 1: Match settlements in user's groups involving the user
995+
{
996+
"$match": {
997+
"groupId": {"$in": group_ids},
998+
"$or": [
999+
{
1000+
"payerId": user_id,
1001+
"payeeId": {"$in": list(friend_ids_in_groups)},
1002+
},
1003+
{
1004+
"payeeId": user_id,
1005+
"payerId": {"$in": list(friend_ids_in_groups)},
1006+
},
1007+
],
1008+
}
1009+
},
1010+
# Step 2: Calculate net balance per friend per group
1011+
{
1012+
"$group": {
1013+
"_id": {
1014+
"friendId": {
1015+
"$cond": [
1016+
{"$eq": ["$payerId", user_id]},
1017+
"$payeeId",
1018+
"$payerId",
1019+
]
1020+
},
1021+
"groupId": "$groupId",
1022+
},
1023+
"balance": {
1024+
"$sum": {
1025+
"$cond": [
1026+
# If user is payer, friend owes user (positive)
1027+
{"$eq": ["$payerId", user_id]},
1028+
"$amount",
1029+
# If user is payee, user owes friend (negative)
1030+
{"$multiply": ["$amount", -1]},
1031+
]
1032+
}
1033+
},
1034+
}
1035+
},
1036+
# Step 3: Group by friend to get total balance across all groups
1037+
{
1038+
"$group": {
1039+
"_id": "$_id.friendId",
1040+
"totalBalance": {"$sum": "$balance"},
1041+
"groups": {
1042+
"$push": {"groupId": "$_id.groupId", "balance": "$balance"}
1043+
},
1044+
}
1045+
},
1046+
# Step 4: Filter out friends with zero balance
1047+
{"$match": {"$expr": {"$gt": [{"$abs": "$totalBalance"}, 0.01]}}},
1048+
]
9791049

980-
for friend_id in friend_ids:
981-
friend_balance_data = {
982-
"userId": friend_id,
983-
"userName": user_names.get(friend_id, "Unknown"),
984-
# Populate image directly from users collection to avoid extra client round-trips
985-
"userImageUrl": user_images.get(friend_id),
986-
"netBalance": 0,
987-
"owesYou": False,
988-
"breakdown": [],
989-
"lastActivity": datetime.utcnow(),
1050+
# Execute aggregation - Single query for all friend balances
1051+
try:
1052+
results = await self.settlements_collection.aggregate(pipeline).to_list(
1053+
length=500
1054+
)
1055+
except Exception as e:
1056+
logger.error(f"Error in optimized friends balance aggregation: {e}")
1057+
results = []
1058+
1059+
if not results:
1060+
# No balances found
1061+
return {
1062+
"friendsBalance": [],
1063+
"summary": {
1064+
"totalOwedToYou": 0,
1065+
"totalYouOwe": 0,
1066+
"netBalance": 0,
1067+
"friendCount": 0,
1068+
"activeGroups": len(groups),
1069+
},
9901070
}
9911071

992-
total_friend_balance = 0
1072+
# Extract unique friend IDs for batch fetching
1073+
friend_ids = list({result["_id"] for result in results})
9931074

994-
# Calculate balance for each group
995-
for group in groups:
996-
group_id = str(group["_id"])
1075+
# Build group map from groups we already fetched
1076+
groups_map = {str(g["_id"]): g.get("name", "Unknown Group") for g in groups}
9971077

998-
# Check if friend is in this group
999-
friend_in_group = any(
1000-
member["userId"] == friend_id for member in group["members"]
1001-
)
1002-
if not friend_in_group:
1003-
continue
1078+
# OPTIMIZATION: Batch fetch all friend details in one query
1079+
try:
1080+
friends_cursor = self.users_collection.find(
1081+
{"_id": {"$in": [ObjectId(fid) for fid in friend_ids]}},
1082+
{"_id": 1, "name": 1, "imageUrl": 1},
1083+
)
1084+
friends_list = await friends_cursor.to_list(length=500)
1085+
friends_map = {str(f["_id"]): f for f in friends_list}
1086+
except Exception as e:
1087+
logger.error(f"Error batch fetching friend details: {e}")
1088+
friends_map = {}
10041089

1005-
# Calculate net balance between user and friend in this group
1006-
pipeline = [
1007-
{
1008-
"$match": {
1009-
"groupId": group_id,
1010-
"$or": [
1011-
{"payerId": user_id, "payeeId": friend_id},
1012-
{"payerId": friend_id, "payeeId": user_id},
1013-
],
1014-
}
1015-
},
1016-
{
1017-
"$group": {
1018-
"_id": None,
1019-
"userOwes": {
1020-
"$sum": {
1021-
"$cond": [
1022-
{
1023-
"$and": [
1024-
{"$eq": ["$payerId", friend_id]},
1025-
{"$eq": ["$payeeId", user_id]},
1026-
]
1027-
},
1028-
"$amount",
1029-
0,
1030-
]
1031-
}
1032-
},
1033-
"friendOwes": {
1034-
"$sum": {
1035-
"$cond": [
1036-
{
1037-
"$and": [
1038-
{"$eq": ["$payerId", user_id]},
1039-
{"$eq": ["$payeeId", friend_id]},
1040-
]
1041-
},
1042-
"$amount",
1043-
0,
1044-
]
1045-
}
1046-
},
1047-
}
1048-
},
1049-
]
1090+
# Post-process results to build final response
1091+
friends_balance = []
1092+
user_totals = {"totalOwedToYou": 0, "totalYouOwe": 0}
10501093

1051-
result = await self.settlements_collection.aggregate(pipeline).to_list(
1052-
None
1053-
)
1054-
balance_data = result[0] if result else {"userOwes": 0, "friendOwes": 0}
1094+
for result in results:
1095+
friend_id = result["_id"]
1096+
total_balance = result.get("totalBalance", 0)
10551097

1056-
group_balance = balance_data["friendOwes"] - balance_data["userOwes"]
1057-
total_friend_balance += group_balance
1098+
# Get friend details from map
1099+
friend_details = friends_map.get(friend_id)
10581100

1059-
if (
1060-
abs(group_balance) > 0.01
1061-
): # Only include if there's a significant balance
1062-
friend_balance_data["breakdown"].append(
1101+
# Build breakdown by group
1102+
breakdown = []
1103+
for group_item in result.get("groups", []):
1104+
group_id = group_item["groupId"]
1105+
group_balance = group_item["balance"]
1106+
1107+
# Only include groups with significant balance
1108+
if abs(group_balance) > 0.01:
1109+
breakdown.append(
10631110
{
10641111
"groupId": group_id,
1065-
"groupName": group["name"],
1066-
"balance": group_balance,
1112+
"groupName": groups_map.get(group_id, "Unknown Group"),
1113+
"balance": round(group_balance, 2),
10671114
"owesYou": group_balance > 0,
10681115
}
10691116
)
10701117

1071-
if (
1072-
abs(total_friend_balance) > 0.01
1073-
): # Only include friends with non-zero balance
1074-
friend_balance_data["netBalance"] = total_friend_balance
1075-
friend_balance_data["owesYou"] = total_friend_balance > 0
1118+
# Build friend balance object
1119+
friend_data = {
1120+
"userId": friend_id,
1121+
"userName": (
1122+
friend_details.get("name", "Unknown")
1123+
if friend_details
1124+
else "Unknown"
1125+
),
1126+
"userImageUrl": (
1127+
friend_details.get("imageUrl") if friend_details else None
1128+
),
1129+
"netBalance": round(total_balance, 2),
1130+
"owesYou": total_balance > 0,
1131+
"breakdown": breakdown,
1132+
"lastActivity": datetime.now(
1133+
timezone.utc
1134+
), # TODO: Calculate actual last activity
1135+
}
10761136

1077-
if total_friend_balance > 0:
1078-
user_totals["totalOwedToYou"] += total_friend_balance
1079-
else:
1080-
user_totals["totalYouOwe"] += abs(total_friend_balance)
1137+
friends_balance.append(friend_data)
10811138

1082-
friends_balance.append(friend_balance_data)
1139+
# Update totals
1140+
if total_balance > 0:
1141+
user_totals["totalOwedToYou"] += total_balance
1142+
else:
1143+
user_totals["totalYouOwe"] += abs(total_balance)
10831144

10841145
return {
10851146
"friendsBalance": friends_balance,
10861147
"summary": {
1087-
"totalOwedToYou": user_totals["totalOwedToYou"],
1088-
"totalYouOwe": user_totals["totalYouOwe"],
1089-
"netBalance": user_totals["totalOwedToYou"]
1090-
- user_totals["totalYouOwe"],
1148+
"totalOwedToYou": round(user_totals["totalOwedToYou"], 2),
1149+
"totalYouOwe": round(user_totals["totalYouOwe"], 2),
1150+
"netBalance": round(
1151+
user_totals["totalOwedToYou"] - user_totals["totalYouOwe"], 2
1152+
),
10911153
"friendCount": len(friends_balance),
10921154
"activeGroups": len(groups),
10931155
},

0 commit comments

Comments
 (0)