Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug Fix for Transaction Read/Write Error #401

Merged
merged 16 commits into from
Feb 29, 2024
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@acmucsd/membership-portal",
"version": "3.3.0",
"version": "3.4.0",
"description": "REST API for ACM UCSD's membership portal.",
"main": "index.d.ts",
"files": [
Expand Down Expand Up @@ -39,6 +39,7 @@
"dependencies": {
"@sendgrid/mail": "^7.4.4",
"amazon-s3-uri": "^0.1.1",
"async-retry": "^1.3.3",
"aws-sdk": "^2.906.0",
"bcrypt": "^5.0.0",
"body-parser": "^1.19.0",
Expand Down Expand Up @@ -71,6 +72,7 @@
"winston-daily-rotate-file": "^4.5.0"
},
"devDependencies": {
"@types/async-retry": "^1.4.8",
"@types/bcrypt": "^3.0.0",
"@types/body-parser": "^1.19.0",
"@types/datadog-metrics": "^0.6.1",
Expand Down
31 changes: 29 additions & 2 deletions repositories/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EntityManager } from 'typeorm';
import AsyncRetry = require('async-retry');
import { UserRepository } from './UserRepository';
import { FeedbackRepository } from './FeedbackRepository';
import { AttendanceRepository, ExpressCheckinRepository } from './AttendanceRepository';
Expand All @@ -16,6 +17,9 @@ import { LeaderboardRepository } from './LeaderboardRepository';
import { ResumeRepository } from './ResumeRepository';
import { UserSocialMediaRepository } from './UserSocialMediaRepository';

const HINT_FOR_RETRIABLE_TRANSACTIONS : string = 'The transaction might succeed if retried.';
const AMOUNT_OF_RETRIES : number = 10;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we consistent with declaring numbers like this at the top of the file or do we generally do this in the Config? small nit either way but for the sake of consistency


export default class Repositories {
public static activity(transactionalEntityManager: EntityManager): ActivityRepository {
return transactionalEntityManager.getCustomRepository(ActivityRepository);
Expand Down Expand Up @@ -93,11 +97,34 @@ export class TransactionsManager {
this.transactionalEntityManager = transactionalEntityManager;
}

// used async-retry library to handle automatic retries under transaction failure due to concurrent transactions
public readOnly<T>(fn: (transactionalEntityManager: EntityManager) => Promise<T>): Promise<T> {
return this.transactionalEntityManager.transaction('REPEATABLE READ', fn);
return AsyncRetry(async (bail, attemptNum) => {
try {
const res = await this.transactionalEntityManager.transaction('REPEATABLE READ', fn);
nik-dange marked this conversation as resolved.
Show resolved Hide resolved
return res;
} catch (e) {
if (e.hint !== HINT_FOR_RETRIABLE_TRANSACTIONS) bail(e);
else throw e;
}
},
{
retries: AMOUNT_OF_RETRIES,
});
}

public readWrite<T>(fn: (transactionalEntityManager: EntityManager) => Promise<T>): Promise<T> {
return this.transactionalEntityManager.transaction('SERIALIZABLE', fn);
return AsyncRetry(async (bail, attemptNum) => {
try {
const res = await this.transactionalEntityManager.transaction('SERIALIZABLE', fn);
return res;
} catch (e) {
if (e.hint !== HINT_FOR_RETRIABLE_TRANSACTIONS) bail(e);
else throw e;
}
},
{
retries: AMOUNT_OF_RETRIES,
});
}
}
5 changes: 0 additions & 5 deletions tests/Seeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,6 @@ async function seed(): Promise<void> {
});
const MERCH_ITEM_1_PHOTO_1 = MerchFactory.fakePhoto({
merchItem: MERCH_ITEM_1,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: maybe add these uploadedPhoto links back in for seeding data purposes now that you're done testing on the local frontend

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest, the link before was just a dummy link, and since I added the default dummy link to the MerchFactory, I think it is no longer necessary to have it here. Do you think it is better to declare it explicitly instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as there's no issues, I think it's fine to have it in the MerchFactory since it's a generic property of all merch item photos. If we do this though, maybe we should consider abstracting away any other common properties for seed data that we create. not high priority but something to keep in mind

uploadedPhoto: 'https://www.fakepicture.com/',
position: 1,
});
const MERCH_ITEM_1_PHOTO_2 = MerchFactory.fakePhoto({
Expand Down Expand Up @@ -542,7 +541,6 @@ async function seed(): Promise<void> {
];
const MERCH_ITEM_2_PHOTO_0 = MerchFactory.fakePhoto({
merchItem: MERCH_ITEM_2,
uploadedPhoto: 'https://www.fakepicture.com/',
position: 0,
});
const MERCH_ITEM_2_PHOTO_1 = MerchFactory.fakePhoto({
Expand Down Expand Up @@ -591,7 +589,6 @@ async function seed(): Promise<void> {
});
const MERCH_ITEM_3_PHOTO_1 = MerchFactory.fakePhoto({
merchItem: MERCH_ITEM_3,
uploadedPhoto: 'https://www.fakepicture.com/',
position: 1,
});
const MERCH_ITEM_3_PHOTO_2 = MerchFactory.fakePhoto({
Expand All @@ -601,12 +598,10 @@ async function seed(): Promise<void> {
});
const MERCH_ITEM_3_PHOTO_3 = MerchFactory.fakePhoto({
merchItem: MERCH_ITEM_3,
uploadedPhoto: 'https://www.fakepicture.com/',
position: 3,
});
const MERCH_ITEM_3_PHOTO_4 = MerchFactory.fakePhoto({
merchItem: MERCH_ITEM_3,
uploadedPhoto: 'https://www.fakepicture.com/',
position: 4,
});
MERCH_ITEM_3.merchPhotos = [
Expand Down
4 changes: 2 additions & 2 deletions tests/data/EventFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ export class EventFactory {
requiresStaff: FactoryUtils.getRandomBoolean(),
staffPointBonus: EventFactory.randomPointValue(),
committee: 'ACM',
cover: faker.internet.url(),
cover: FactoryUtils.getRandomImageUrl(),
deleted: false,
eventLink: faker.internet.url(),
thumbnail: faker.internet.url(),
thumbnail: FactoryUtils.getRandomImageUrl(),
});
return EventModel.merge(fake, substitute);
}
Expand Down
4 changes: 4 additions & 0 deletions tests/data/FactoryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ export default class FactoryUtils {
public static randomHexString(): string {
return faker.datatype.hexaDecimal(10);
}

public static getRandomImageUrl(): string {
return `http://i.imgur.com/${faker.random.alphaNumeric(10)}.jpeg`;
}
}
2 changes: 1 addition & 1 deletion tests/data/MerchFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class MerchFactory {
const fake = MerchandiseItemPhotoModel.create({
uuid: uuid(),
position: 0,
uploadedPhoto: 'https://www.fakepicture.com/',
uploadedPhoto: FactoryUtils.getRandomImageUrl(),
uploadedAt: faker.date.recent(),
});
return MerchandiseItemPhotoModel.merge(fake, substitute);
Expand Down
189 changes: 185 additions & 4 deletions tests/userSocialMedia.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import faker = require('faker');
import { Connection } from 'typeorm';
import { UserModel } from '../models/UserModel';
import { SocialMediaType } from '../types';
import { ControllerFactory } from './controllers';
import { DatabaseConnection, PortalState, UserFactory } from './data';
import { UserSocialMediaFactory } from './data/UserSocialMediaFactory';
import { UserController } from '../api/controllers/UserController';

beforeAll(async () => {
await DatabaseConnection.connect();
});

beforeEach(async () => {
await DatabaseConnection.clear();
});

afterAll(async () => {
await DatabaseConnection.clear();
await DatabaseConnection.close();
});

describe('social media URL submission', () => {
beforeEach(async () => {
await DatabaseConnection.clear();
});

test('properly persists on successful submission', async () => {
const conn = await DatabaseConnection.get();
let member = UserFactory.fake();
Expand Down Expand Up @@ -60,6 +62,10 @@ describe('social media URL submission', () => {
});

describe('social media URL update', () => {
beforeEach(async () => {
await DatabaseConnection.clear();
});

test('is invalidated when updating social media URL of another user', async () => {
const conn = await DatabaseConnection.get();
let member = UserFactory.fake();
Expand Down Expand Up @@ -100,6 +106,10 @@ describe('social media URL update', () => {
});

describe('social media URL delete', () => {
beforeEach(async () => {
await DatabaseConnection.clear();
});

test('is invalidated when deleting social media URL of another user', async () => {
const conn = await DatabaseConnection.get();
let member = UserFactory.fake();
Expand All @@ -119,3 +129,174 @@ describe('social media URL delete', () => {
await expect(userController.deleteSocialMediaForUser(uuidParams, unauthorizedMember)).rejects.toThrow(errorMessage);
});
});

describe('social media URL update', () => {
let conn : Connection;
let member : UserModel;
let userController : UserController;

let flag : boolean = false;
function waitForFlag(interval = 500, timeout = 5000) {
let acc = 0; // time accumulation
return new Promise((resolve, reject) => {
const i = setInterval(() => {
acc += interval;
if (flag) {
clearInterval(i);
resolve(flag);
}
if (acc > timeout) {
clearInterval(i);
reject();
}
}, interval);
});
}

// beforeAll does not behave properly when concurrent is being used:
// https://stackoverflow.com/questions/42633635/how-to-run-concurrent-tests-in-jest-with-multiple-tests-per-request
beforeAll(async () => {
await DatabaseConnection.clear();
conn = await DatabaseConnection.get();
member = UserFactory.fake();

const userSocialMedia0 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.FACEBOOK });
const userSocialMedia1 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.GITHUB });
const userSocialMedia2 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.DEVPOST });
const userSocialMedia3 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.EMAIL });
const userSocialMedia4 = UserSocialMediaFactory.fake({ user: member, type: SocialMediaType.INSTAGRAM });

await new PortalState()
.createUsers(member)
.createUserSocialMedia(
member, userSocialMedia0, userSocialMedia1, userSocialMedia2, userSocialMedia3, userSocialMedia4,
).write();

userController = ControllerFactory.user(conn);

// refreshes member to have the userSocialMedia field
member = await conn.manager.findOne(UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] });

flag = true;
});

test.concurrent('concurrent updates properly persist on successful submission 0', async () => {
await waitForFlag();
const index = 0;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);

const expectedUserSocialMedia0 = {
url: socialMediaParams.socialMedia.url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
);

expect(updatedMember.userSocialMedia).toHaveLength(5);
const targetUserSocialMedia = updatedMember.userSocialMedia.find(
(socialMedia) => socialMedia.uuid === expectedUserSocialMedia0.uuid,
);
expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia0.url);
expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia0.type);
});

test.concurrent('concurrent updates properly persist on successful submission 1', async () => {
await waitForFlag();
const index = 1;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);

const expectedUserSocialMedia1 = {
url: socialMediaParams.socialMedia.url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
);

expect(updatedMember.userSocialMedia).toHaveLength(5);
const targetUserSocialMedia = updatedMember.userSocialMedia.find(
(socialMedia) => socialMedia.uuid === expectedUserSocialMedia1.uuid,
);
expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia1.url);
expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia1.type);
});

test.concurrent('concurrent updates properly persist on successful submission 2', async () => {
await waitForFlag();
const index = 2;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);

const expectedUserSocialMedia2 = {
url: socialMediaParams.socialMedia.url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
);

expect(updatedMember.userSocialMedia).toHaveLength(5);
const targetUserSocialMedia = updatedMember.userSocialMedia.find(
(socialMedia) => socialMedia.uuid === expectedUserSocialMedia2.uuid,
);
expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia2.url);
expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia2.type);
});

test.concurrent('concurrent updates properly persist on successful submission 3', async () => {
await waitForFlag();
const index = 3;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);

const expectedUserSocialMedia3 = {
url: socialMediaParams.socialMedia.url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
);

expect(updatedMember.userSocialMedia).toHaveLength(5);
const targetUserSocialMedia = updatedMember.userSocialMedia.find(
(socialMedia) => socialMedia.uuid === expectedUserSocialMedia3.uuid,
);
expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia3.url);
expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia3.type);
});

test.concurrent('concurrent updates properly persist on successful submission 4', async () => {
await waitForFlag();
const index = 4;
const uuidParams = { uuid: member.userSocialMedia[index].uuid };
const socialMediaParams = { socialMedia: { url: faker.internet.url() } };
await userController.updateSocialMediaForUser(uuidParams, socialMediaParams, member);

const expectedUserSocialMedia4 = {
url: socialMediaParams.socialMedia.url,
type: member.userSocialMedia[index].type,
uuid: uuidParams.uuid,
};
const updatedMember = await conn.manager.findOne(
UserModel, { uuid: member.uuid }, { relations: ['userSocialMedia'] },
);

expect(updatedMember.userSocialMedia).toHaveLength(5);
const targetUserSocialMedia = updatedMember.userSocialMedia.find(
(socialMedia) => socialMedia.uuid === expectedUserSocialMedia4.uuid,
);
expect(targetUserSocialMedia.url).toEqual(expectedUserSocialMedia4.url);
expect(targetUserSocialMedia.type).toEqual(expectedUserSocialMedia4.type);
});
});
Loading
Loading