Skip to content

Commit af20480

Browse files
fix(core): apply pessimistic locking to prevent order modification race conditions
1 parent becce09 commit af20480

File tree

2 files changed

+103
-24
lines changed

2 files changed

+103
-24
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { createTestEnvironment } from '@vendure/testing';
2+
import * as path from 'path';
3+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
4+
5+
import { initialData } from '../../e2e-common/e2e-initial-data';
6+
import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../e2e-common/test-config';
7+
8+
describe('Order race conditions', () => {
9+
const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
10+
11+
beforeAll(async () => {
12+
await server.init({
13+
initialData,
14+
productsCsvPath: path.join(__dirname, '../../e2e-common/fixtures/e2e-products-full.csv'),
15+
customerCount: 1,
16+
});
17+
await shopClient.asUserWithCredentials('test@vendure.io', 'test');
18+
}, TEST_SETUP_TIMEOUT_MS);
19+
20+
afterAll(async () => {
21+
await server.destroy();
22+
});
23+
24+
it('handles parallel modifications to the order correctly', async () => {
25+
const ADD_ITEM_TO_ORDER = `
26+
mutation AddItemToOrder($productVariantId: ID!, $quantity: Int!) {
27+
addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) {
28+
... on Order {
29+
id
30+
totalQuantity
31+
total
32+
}
33+
... on ErrorResult {
34+
errorCode
35+
message
36+
}
37+
}
38+
}
39+
`;
40+
41+
const variantId = 'T_1'; // Laptop 13 inch 8GB
42+
const quantityPerRequest = 1;
43+
const concurrency = 5;
44+
45+
// Ejecutamos 5 peticiones simultáneas para añadir el item
46+
const promises: Array<Promise<any>> = [];
47+
for (let i = 0; i < concurrency; i++) {
48+
promises.push(
49+
shopClient.query(ADD_ITEM_TO_ORDER, {
50+
productVariantId: variantId,
51+
quantity: quantityPerRequest,
52+
}),
53+
);
54+
}
55+
56+
await Promise.all(promises);
57+
58+
const { activeOrder } = await shopClient.query(`
59+
query GetActiveOrder { activeOrder { totalQuantity } }
60+
`);
61+
62+
// Si hay condición de carrera, el total será menor a 5 (algunas escrituras se sobrescribieron)
63+
expect(activeOrder.totalQuantity).toBe(concurrency * quantityPerRequest);
64+
});
65+
});

packages/core/src/service/services/order.service.ts

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export class OrderService {
215215
ctx: RequestContext,
216216
orderId: ID,
217217
relations?: RelationPaths<Order>,
218+
lock?: { mode: 'pessimistic_read' | 'pessimistic_write' },
218219
): Promise<Order | undefined> {
219220
const qb = this.connection.getRepository(ctx, Order).createQueryBuilder('order');
220221
const effectiveRelations = relations ?? [
@@ -251,6 +252,7 @@ export class OrderService {
251252
qb.setFindOptions({
252253
relations: orderRelations,
253254
relationLoadStrategy: 'query',
255+
lock,
254256
})
255257
.leftJoin('order.channels', 'channel')
256258
.where('order.id = :orderId', { orderId })
@@ -501,7 +503,7 @@ export class OrderService {
501503
* Updates the custom fields of an Order.
502504
*/
503505
async updateCustomFields(ctx: RequestContext, orderId: ID, customFields: any) {
504-
let order = await this.getOrderOrThrow(ctx, orderId);
506+
let order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
505507
order = patchEntity(order, { customFields });
506508
const updatedOrder = await this.connection.getRepository(ctx, Order).save(order);
507509
await this.customFieldRelationService.updateRelations(ctx, Order, { customFields }, updatedOrder);
@@ -517,7 +519,9 @@ export class OrderService {
517519
* @since 2.2.0
518520
*/
519521
async updateOrderCustomer(ctx: RequestContext, { customerId, orderId, note }: SetOrderCustomerInput) {
520-
const order = await this.getOrderOrThrow(ctx, orderId, ['channels', 'customer']);
522+
const order = await this.getOrderOrThrow(ctx, orderId, ['channels', 'customer'], {
523+
mode: 'pessimistic_write',
524+
});
521525
const currentCustomer = order.customer;
522526
if (currentCustomer?.id === customerId) {
523527
// No change in customer, so just return the order as-is
@@ -605,7 +609,7 @@ export class OrderService {
605609
}>,
606610
relations?: RelationPaths<Order>,
607611
): Promise<{ order: Order; errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> }> {
608-
const order = await this.getOrderOrThrow(ctx, orderId);
612+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
609613
const errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> = [];
610614
const updatedOrderLines: OrderLine[] = [];
611615
addItem: for (const item of items) {
@@ -752,7 +756,7 @@ export class OrderService {
752756
lines: Array<{ orderLineId: ID; quantity: number; customFields?: { [key: string]: any } }>,
753757
relations?: RelationPaths<Order>,
754758
): Promise<{ order: Order; errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> }> {
755-
const order = await this.getOrderOrThrow(ctx, orderId);
759+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
756760
const errorResults: Array<JustErrorResults<UpdateOrderItemsResult>> = [];
757761
const updatedOrderLines: OrderLine[] = [];
758762
adjustLine: for (const line of lines) {
@@ -880,7 +884,7 @@ export class OrderService {
880884
orderId: ID,
881885
orderLineIds: ID[],
882886
): Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>> {
883-
const order = await this.getOrderOrThrow(ctx, orderId);
887+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
884888
const validationError = this.assertAddingItemsState(order);
885889
if (validationError) {
886890
return validationError;
@@ -923,7 +927,7 @@ export class OrderService {
923927
ctx: RequestContext,
924928
orderId: ID,
925929
): Promise<ErrorResultUnion<RemoveOrderItemsResult, Order>> {
926-
const order = await this.getOrderOrThrow(ctx, orderId);
930+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
927931
const validationError = this.assertAddingItemsState(order);
928932
if (validationError) {
929933
return validationError;
@@ -956,7 +960,7 @@ export class OrderService {
956960
orderId: ID,
957961
surchargeInput: Partial<Omit<Surcharge, 'id' | 'createdAt' | 'updatedAt' | 'order'>>,
958962
): Promise<Order> {
959-
const order = await this.getOrderOrThrow(ctx, orderId);
963+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
960964
const surcharge = await this.connection.getRepository(ctx, Surcharge).save(
961965
new Surcharge({
962966
taxLines: [],
@@ -976,7 +980,7 @@ export class OrderService {
976980
* Removes a {@link Surcharge} from the Order.
977981
*/
978982
async removeSurchargeFromOrder(ctx: RequestContext, orderId: ID, surchargeId: ID): Promise<Order> {
979-
const order = await this.getOrderOrThrow(ctx, orderId);
983+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
980984
const surcharge = await this.connection.getEntityOrThrow(ctx, Surcharge, surchargeId);
981985
if (order.surcharges.find(s => idsAreEqual(s.id, surcharge.id))) {
982986
order.surcharges = order.surcharges.filter(s => !idsAreEqual(s.id, surchargeId));
@@ -998,7 +1002,7 @@ export class OrderService {
9981002
orderId: ID,
9991003
couponCode: string,
10001004
): Promise<ErrorResultUnion<ApplyCouponCodeResult, Order>> {
1001-
const order = await this.getOrderOrThrow(ctx, orderId);
1005+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
10021006
if (order.couponCodes.includes(couponCode)) {
10031007
return order;
10041008
}
@@ -1026,7 +1030,7 @@ export class OrderService {
10261030
* Removes a coupon code from the Order.
10271031
*/
10281032
async removeCouponCode(ctx: RequestContext, orderId: ID, couponCode: string) {
1029-
const order = await this.getOrderOrThrow(ctx, orderId);
1033+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
10301034
if (order.couponCodes.includes(couponCode)) {
10311035
// When removing a couponCode which has triggered an Order-level discount
10321036
// we need to make sure we persist the changes to the adjustments array of
@@ -1077,7 +1081,7 @@ export class OrderService {
10771081
* Sets the shipping address for the Order.
10781082
*/
10791083
async setShippingAddress(ctx: RequestContext, orderId: ID, input: CreateAddressInput): Promise<Order> {
1080-
const order = await this.getOrderOrThrow(ctx, orderId);
1084+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
10811085
const country = await this.countryService.findOneByCode(ctx, input.countryCode);
10821086
const shippingAddress = { ...input, countryCode: input.countryCode, country: country.name };
10831087
await this.connection
@@ -1101,7 +1105,7 @@ export class OrderService {
11011105
* Sets the billing address for the Order.
11021106
*/
11031107
async setBillingAddress(ctx: RequestContext, orderId: ID, input: CreateAddressInput): Promise<Order> {
1104-
const order = await this.getOrderOrThrow(ctx, orderId);
1108+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
11051109
const country = await this.countryService.findOneByCode(ctx, input.countryCode);
11061110
const billingAddress = { ...input, countryCode: input.countryCode, country: country.name };
11071111
await this.connection
@@ -1127,7 +1131,7 @@ export class OrderService {
11271131
* @since 3.1.0
11281132
*/
11291133
async unsetShippingAddress(ctx: RequestContext, orderId: ID): Promise<Order> {
1130-
const order = await this.getOrderOrThrow(ctx, orderId);
1134+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
11311135
await this.connection
11321136
.getRepository(ctx, Order)
11331137
.createQueryBuilder('order')
@@ -1151,7 +1155,7 @@ export class OrderService {
11511155
* @since 3.1.0
11521156
*/
11531157
async unsetBillingAddress(ctx: RequestContext, orderId: ID): Promise<Order> {
1154-
const order = await this.getOrderOrThrow(ctx, orderId);
1158+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
11551159
await this.connection
11561160
.getRepository(ctx, Order)
11571161
.createQueryBuilder('order')
@@ -1212,7 +1216,7 @@ export class OrderService {
12121216
orderId: ID,
12131217
shippingMethodIds: ID[],
12141218
): Promise<ErrorResultUnion<SetOrderShippingMethodResult, Order>> {
1215-
const order = await this.getOrderOrThrow(ctx, orderId);
1219+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
12161220
const validationError = this.assertAddingItemsState(order);
12171221
if (validationError) {
12181222
return validationError;
@@ -1221,7 +1225,9 @@ export class OrderService {
12211225
if (isGraphQlErrorResult(result)) {
12221226
return result;
12231227
}
1224-
const updatedOrder = await this.getOrderOrThrow(ctx, orderId);
1228+
const updatedOrder = await this.getOrderOrThrow(ctx, orderId, undefined, {
1229+
mode: 'pessimistic_write',
1230+
});
12251231
await this.applyPriceAdjustments(ctx, updatedOrder);
12261232
return this.connection.getRepository(ctx, Order).save(updatedOrder);
12271233
}
@@ -1235,7 +1241,7 @@ export class OrderService {
12351241
orderId: ID,
12361242
state: OrderState,
12371243
): Promise<Order | OrderStateTransitionError> {
1238-
const order = await this.getOrderOrThrow(ctx, orderId);
1244+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
12391245
order.payments = await this.getOrderPayments(ctx, orderId);
12401246
const fromState = order.state;
12411247
let finalize: () => Promise<any>;
@@ -1321,7 +1327,9 @@ export class OrderService {
13211327
ctx: RequestContext,
13221328
input: ModifyOrderInput,
13231329
): Promise<ErrorResultUnion<ModifyOrderResult, Order>> {
1324-
const order = await this.getOrderOrThrow(ctx, input.orderId);
1330+
const order = await this.getOrderOrThrow(ctx, input.orderId, undefined, {
1331+
mode: 'pessimistic_write',
1332+
});
13251333
const result = await this.orderModifier.modifyOrder(ctx, input, order);
13261334

13271335
if (isGraphQlErrorResult(result)) {
@@ -1340,7 +1348,7 @@ export class OrderService {
13401348
modificationId: result.modification.id,
13411349
},
13421350
});
1343-
return this.getOrderOrThrow(ctx, input.orderId);
1351+
return this.getOrderOrThrow(ctx, input.orderId, undefined, { mode: 'pessimistic_write' });
13441352
}
13451353

13461354
/**
@@ -1365,7 +1373,7 @@ export class OrderService {
13651373
orderId: ID,
13661374
input: PaymentInput,
13671375
): Promise<ErrorResultUnion<AddPaymentToOrderResult, Order>> {
1368-
const order = await this.getOrderOrThrow(ctx, orderId);
1376+
const order = await this.getOrderOrThrow(ctx, orderId, undefined, { mode: 'pessimistic_write' });
13691377
if (!this.canAddPaymentToOrder(order)) {
13701378
return new OrderPaymentStateError();
13711379
}
@@ -1434,7 +1442,9 @@ export class OrderService {
14341442
ctx: RequestContext,
14351443
input: ManualPaymentInput,
14361444
): Promise<ErrorResultUnion<AddManualPaymentToOrderResult, Order>> {
1437-
const order = await this.getOrderOrThrow(ctx, input.orderId);
1445+
const order = await this.getOrderOrThrow(ctx, input.orderId, undefined, {
1446+
mode: 'pessimistic_write',
1447+
});
14381448
if (order.state !== 'ArrangingAdditionalPayment' && order.state !== 'ArrangingPayment') {
14391449
return new ManualPaymentStateError();
14401450
}
@@ -1683,7 +1693,9 @@ export class OrderService {
16831693
}
16841694

16851695
private async cancelOrderById(ctx: RequestContext, input: CancelOrderInput) {
1686-
const order = await this.getOrderOrThrow(ctx, input.orderId);
1696+
const order = await this.getOrderOrThrow(ctx, input.orderId, undefined, {
1697+
mode: 'pessimistic_write',
1698+
});
16871699
if (order.active) {
16881700
return true;
16891701
} else {
@@ -1775,7 +1787,7 @@ export class OrderService {
17751787
const order =
17761788
orderIdOrOrder instanceof Order
17771789
? orderIdOrOrder
1778-
: await this.getOrderOrThrow(ctx, orderIdOrOrder);
1790+
: await this.getOrderOrThrow(ctx, orderIdOrOrder, undefined, { mode: 'pessimistic_write' });
17791791
order.customer = customer;
17801792
await this.connection.getRepository(ctx, Order).save(order, { reload: false });
17811793
let updatedOrder = order;
@@ -1801,7 +1813,7 @@ export class OrderService {
18011813
* Creates a new "ORDER_NOTE" type {@link OrderHistoryEntry} in the Order's history timeline.
18021814
*/
18031815
async addNoteToOrder(ctx: RequestContext, input: AddNoteToOrderInput): Promise<Order> {
1804-
const order = await this.getOrderOrThrow(ctx, input.id);
1816+
const order = await this.getOrderOrThrow(ctx, input.id, undefined, { mode: 'pessimistic_write' });
18051817
await this.historyService.createHistoryEntryForOrder(
18061818
{
18071819
ctx,
@@ -1969,6 +1981,7 @@ export class OrderService {
19691981
ctx: RequestContext,
19701982
orderId: ID,
19711983
relations?: RelationPaths<Order>,
1984+
lock?: { mode: 'pessimistic_read' | 'pessimistic_write' },
19721985
): Promise<Order> {
19731986
const order = await this.findOne(
19741987
ctx,
@@ -1981,6 +1994,7 @@ export class OrderService {
19811994
'surcharges',
19821995
'customer',
19831996
],
1997+
lock,
19841998
);
19851999
if (!order) {
19862000
throw new EntityNotFoundError('Order', orderId);

0 commit comments

Comments
 (0)