Skip to content

Commit 8ca663c

Browse files
author
JaehyuckSa
committed
fix: reduce checkout lock queries
1 parent 78aad01 commit 8ca663c

2 files changed

Lines changed: 117 additions & 7 deletions

File tree

app/shop/payment_history/services.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart, TicketInfo
88
from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, is_legal_payment_status_transition
99
from shop.payment_history.tasks import send_payment_completed_notifications
10-
from shop.product.models import Option, OptionGroup, Product, Tag
10+
from shop.product.models import Option, OptionGroup, Product, ProductTagRelation, Tag
1111
from shop.serializers.cart_validation import (
1212
CartOrderableCheckSerializer,
1313
OrderableCheckSerializerMode,
@@ -17,6 +17,9 @@
1717
FREE_CHECKOUT_PRICE_ERROR = "무료 주문은 결제 준비 금액이 0원이어야 합니다."
1818
FREE_CHECKOUT_TARGET_ERROR = "무료 주문 대상을 찾을 수 없습니다."
1919
FREE_CHECKOUT_TRANSITION_ERROR = "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다."
20+
_LOCKED_OPTIONS_ATTR = "_locked_checkout_options"
21+
_LOCKED_TICKET_INFO_ATTR = "_locked_checkout_ticket_info"
22+
_MISSING = object()
2023

2124

2225
def complete_free_checkout(cart_or_order: Order | SingleProductCart) -> Order:
@@ -95,7 +98,10 @@ def _lock_order_allocation_rows(order: Order) -> list[OrderProductRelation]:
9598
return product_rels
9699

97100
list(Product.objects.select_for_update().filter_active().filter(id__in=product_ids).order_by("id"))
98-
list(Tag.objects.select_for_update().filter_active().filter(products__product_id__in=product_ids).order_by("id"))
101+
active_tag_ids = (
102+
ProductTagRelation.objects.filter_active().filter(product_id__in=product_ids).order_by().values("tag_id")
103+
)
104+
list(Tag.objects.select_for_update().filter_active().filter(id__in=active_tag_ids).order_by("id"))
99105

100106
option_groups = list(
101107
OptionGroup.objects.select_for_update().filter_active().filter(product_id__in=product_ids).order_by("id")
@@ -105,20 +111,37 @@ def _lock_order_allocation_rows(order: Order) -> list[OrderProductRelation]:
105111
if option_group_ids:
106112
list(Option.objects.select_for_update().filter_active().filter(group_id__in=option_group_ids).order_by("id"))
107113

108-
list(
114+
option_rels = list(
109115
OrderProductOptionRelation.objects.select_for_update()
110116
.filter_active()
111117
.filter(order_product_relation_id__in=product_rel_ids)
112118
.order_by("id")
113119
)
114-
list(TicketInfo.objects.select_for_update().filter_active().filter(order_product_relation_id__in=product_rel_ids))
120+
ticket_infos = list(
121+
TicketInfo.objects.select_for_update().filter_active().filter(order_product_relation_id__in=product_rel_ids)
122+
)
123+
124+
option_rels_by_product_rel_id: dict[int, list[OrderProductOptionRelation]] = {}
125+
for option_rel in option_rels:
126+
option_rels_by_product_rel_id.setdefault(option_rel.order_product_relation_id, []).append(option_rel)
127+
128+
ticket_infos_by_product_rel_id = {
129+
ticket_info.order_product_relation_id: ticket_info for ticket_info in ticket_infos
130+
}
131+
for product_rel in product_rels:
132+
setattr(product_rel, _LOCKED_OPTIONS_ATTR, option_rels_by_product_rel_id.get(product_rel.id, []))
133+
setattr(product_rel, _LOCKED_TICKET_INFO_ATTR, ticket_infos_by_product_rel_id.get(product_rel.id))
115134

116135
return product_rels
117136

118137

119138
def _build_product_validation_payload(
120139
product_rel: OrderProductRelation, validation_mode: OrderableCheckSerializerMode
121140
) -> dict:
141+
option_rels = getattr(product_rel, _LOCKED_OPTIONS_ATTR, _MISSING)
142+
if option_rels is _MISSING:
143+
option_rels = product_rel.options.filter_active().order_by("id")
144+
122145
payload = {
123146
"product": product_rel.product_id,
124147
"donation_price": product_rel.donation_price,
@@ -128,15 +151,17 @@ def _build_product_validation_payload(
128151
"product_option": option_rel.product_option_id,
129152
"custom_response": option_rel.custom_response,
130153
}
131-
for option_rel in product_rel.options.filter_active().order_by("id")
154+
for option_rel in option_rels
132155
],
133156
}
134157

135158
if (
136159
validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT
137160
and product_rel.product.category.is_ticket
138161
):
139-
ticket_info = getattr(product_rel, "ticket_info", None)
162+
ticket_info = getattr(product_rel, _LOCKED_TICKET_INFO_ATTR, _MISSING)
163+
if ticket_info is _MISSING:
164+
ticket_info = getattr(product_rel, "ticket_info", None)
140165
if ticket_info:
141166
payload["ticket_info"] = {
142167
"name": ticket_info.name,

app/shop/payment_history/test/free_checkout_service_test.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@
44
import pytest
55
from core.const.shop_error_messages import CartNotOrderableErrorMessages, ProductNotOrderableErrorMessages
66
from core.util.testutil import errors_payload
7+
from django.db import transaction
78
from rest_framework.exceptions import ValidationError
89
from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart, TicketInfo
910
from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus
10-
from shop.payment_history.services import complete_free_checkout
11+
from shop.payment_history.services import (
12+
_build_product_validation_payload,
13+
_lock_order_allocation_rows,
14+
complete_free_checkout,
15+
)
16+
from shop.serializers.cart_validation import OrderableCheckSerializerMode
1117

1218

1319
@pytest.mark.django_db
@@ -95,6 +101,85 @@ def test_complete_free_checkout_promotes_single_product_cart(single_product_cart
95101
mock_delay.assert_called_once_with(str(cart_id))
96102

97103

104+
@pytest.mark.django_db
105+
def test_lock_order_allocation_rows_attaches_payload_relations_without_extra_queries(
106+
customer_user, ticket_product, option, django_assert_num_queries
107+
):
108+
order = Order.objects.create(user=customer_user, name="free")
109+
product_rel = OrderProductRelation.objects.create(order=order, product=ticket_product, price=0)
110+
option_rel = OrderProductOptionRelation.objects.create(
111+
order_product_relation=product_rel,
112+
product_option_group=option.group,
113+
product_option=option,
114+
custom_response=None,
115+
)
116+
TicketInfo.objects.create(
117+
order_product_relation=product_rel,
118+
name="김참가",
119+
phone="010-9999-8888",
120+
email="attendee@example.com",
121+
organization="PSK",
122+
contribution_message="응원합니다",
123+
)
124+
125+
with transaction.atomic():
126+
locked_product_rel = _lock_order_allocation_rows(order)[0]
127+
128+
with django_assert_num_queries(0):
129+
payload = _build_product_validation_payload(
130+
locked_product_rel, OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT
131+
)
132+
133+
assert payload == {
134+
"product": ticket_product.id,
135+
"donation_price": 0,
136+
"options": [
137+
{
138+
"product_option_group": option.group_id,
139+
"product_option": option.id,
140+
"custom_response": option_rel.custom_response,
141+
}
142+
],
143+
"ticket_info": {
144+
"name": "김참가",
145+
"phone": "010-9999-8888",
146+
"email": "attendee@example.com",
147+
"organization": "PSK",
148+
"contribution_message": "응원합니다",
149+
},
150+
}
151+
152+
153+
@pytest.mark.django_db
154+
def test_build_product_validation_payload_falls_back_to_related_rows(customer_user, ticket_product, option):
155+
order = Order.objects.create(user=customer_user, name="free")
156+
product_rel = OrderProductRelation.objects.create(order=order, product=ticket_product, price=0)
157+
OrderProductOptionRelation.objects.create(
158+
order_product_relation=product_rel,
159+
product_option_group=option.group,
160+
product_option=option,
161+
custom_response="L",
162+
)
163+
TicketInfo.objects.create(
164+
order_product_relation=product_rel,
165+
name="김참가",
166+
phone="010-9999-8888",
167+
email="attendee@example.com",
168+
organization="PSK",
169+
)
170+
171+
payload = _build_product_validation_payload(product_rel, OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT)
172+
173+
assert payload["options"] == [
174+
{
175+
"product_option_group": option.group_id,
176+
"product_option": option.id,
177+
"custom_response": "L",
178+
}
179+
]
180+
assert payload["ticket_info"]["email"] == "attendee@example.com"
181+
182+
98183
@pytest.mark.django_db
99184
def test_complete_free_checkout_rejects_empty_zero_price_order(customer_user):
100185
order = Order.objects.create(user=customer_user, name="empty")

0 commit comments

Comments
 (0)