Skip to content

Commit

Permalink
feat(app-load-balancing): add logic to balance game load
Browse files Browse the repository at this point in the history
  • Loading branch information
albertodigioacchino committed Jan 25, 2021
1 parent 0f38224 commit 2fcc81c
Show file tree
Hide file tree
Showing 11 changed files with 265 additions and 4 deletions.
2 changes: 2 additions & 0 deletions packages/game-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@hookform/resolvers": "^1.2.0",
"@pipeline/common": "^0.2.0",
"@reduxjs/toolkit": "^1.5.0",
"axios": "^0.21.1",
"firebase": "^8.2.1",
"i18n-js": "^3.8.0",
"react": "^17.0.1",
Expand Down Expand Up @@ -70,6 +71,7 @@
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.6.0",
"@types/axios": "^0.14.0",
"@types/i18n-js": "^3.0.3",
"@types/jest": "^26.0.19",
"@types/node": "^12.19.9",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export interface RequestsKeys {
'game.loadCards': null;
'game.loadGame': null;
createGame: null;
'loadBalancer.status': null;
}
3 changes: 3 additions & 0 deletions packages/game-app/src/_shared/store/rootSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { saga as authSaga } from '@pipeline/auth';
import signupSaga from '../../signup/sagas';
import gameSaga from '../../gameView/sagas';
import createGameSaga from '../../createGame/sagas';
import loadBalancerSaga from '../../loadBalancer/sagas';

import {
runRetrieveDevOpsMaturities as retrieveDevOpsMaturitiesSaga,
runRetrieveGameRoles as retrieveGameRolesMaturitiesSaga,
Expand All @@ -17,6 +19,7 @@ export default function* rootSaga() {
retrieveDevOpsMaturitiesSaga,
gameSaga,
createGameSaga,
loadBalancerSaga,
];
yield all(
sagas.map(saga =>
Expand Down
5 changes: 2 additions & 3 deletions packages/game-app/src/gameView/apis/callLoadGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import 'firebase/firestore';
import { FirebaseCollection, Game } from '@pipeline/common';

export default async function loadGame(gameId: string): Promise<Game> {
const cardDoc = await firebase.firestore().collection(FirebaseCollection.Games).doc(gameId).get();

return cardDoc.data() as Game;
const gameDoc = await firebase.firestore().collection(FirebaseCollection.Games).doc(gameId).get();
return gameDoc.data() as Game;
}
9 changes: 9 additions & 0 deletions packages/game-app/src/gameView/sagas/loadGame.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { call, put, takeEvery } from 'redux-saga/effects';
import { actions, GameState } from '../slice';
import selectBestRTDBInstance from '../../loadBalancer/apis/selectBestRTDBInstance';
import { addRequestStatusManagement } from '@pipeline/requests-status';
import { CardEntity, CardTypes, Game } from '@pipeline/common';
import loadGame from '../apis/callLoadGame';
Expand All @@ -11,6 +12,14 @@ function* executeLoadGame(action: ReturnType<typeof actions.loadGame>) {
const cards: CardEntity[] = yield call(loadCardsForDeck, game.deckId);
yield put(actions.saveCards(cards));
// TODO load actual game state from firestore

if (game.rtdbInstance) {
yield put(actions.saveGame(game));
} else {
const bestRTDBInstance = yield call(selectBestRTDBInstance, action.payload);
yield put(actions.saveGame({ ...game, rtdbInstance: bestRTDBInstance }));
}

const gameState: GameState = {
boardCards: [],
deckCards: cards.filter(c => c.type === CardTypes.PipelineStep).map(c => c.id),
Expand Down
12 changes: 11 additions & 1 deletion packages/game-app/src/gameView/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
EntityState,
PayloadAction,
} from '@reduxjs/toolkit';
import { CardEntity } from '@pipeline/common';
import { CardEntity, Game } from '@pipeline/common';
import { GameUIState } from './types/gameUIState';

export interface AdditionalCardData {
Expand Down Expand Up @@ -53,6 +53,7 @@ export interface GameState {
}

export interface State {
game: Game | null;
cards: EntityState<CardEntity>;
selectedGameId: string | null;
gameState: GameState | null;
Expand All @@ -67,6 +68,7 @@ const adapter = createEntityAdapter<CardEntity>({
});

const initialState = {
game: null,
cards: adapter.getInitialState(),
selectedGameId: null,
gameState: null,
Expand Down Expand Up @@ -134,6 +136,12 @@ const slice = createSlice({
};
}
},
saveGame(state, action: PayloadAction<Game>) {
return {
...state,
game: action.payload,
};
},
},
});

Expand Down Expand Up @@ -162,6 +170,7 @@ const getDeckCardsIds = createSelector(getGameState, getAllCardsEntities, (getGa
});
const getPlacedCards = createSelector(getGameState, getGameState => getGameState?.boardCards);
const getScenario = createSelector(getSlice, slice => slice.scenario);
const getGame = createSelector(getSlice, slice => slice.game);

// TODO try to remove the listener
const getCardStateForUI = createSelector(getGameState, gameState => {
Expand Down Expand Up @@ -205,6 +214,7 @@ export const actions = {
...slice.actions,
loadCards: createAction(`${name}/loadCards`),
loadGame: createAction<string>(`${name}/loadGame`),
updateRTDBInstanceGame: createAction<string>(`${name}/updateRTDBInstanceGame`),
};

export const selectors = {
Expand Down
51 changes: 51 additions & 0 deletions packages/game-app/src/loadBalancer/apis/callUpdateOnlineStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import firebase from 'firebase';
import Database = firebase.database.Database;
import { RTDBPaths, Status } from '@pipeline/common';

export async function startListenToOnlineStatus(
rtdb: Database,
uid: string,
gameId: string,
onConnect: () => void,
onDisconnect: () => void,
) {
const userStatusDatabaseRef = rtdb.ref(`/${RTDBPaths.Statuses}/${uid}`);

const isOfflineForDatabase = {
state: 'offline' as const,
updatedAt: firebase.database.ServerValue.TIMESTAMP,
gameId: null,
} as Status;

const isOnlineForDatabase = {
state: 'online' as const,
updatedAt: firebase.database.ServerValue.TIMESTAMP,
gameId,
} as Status;

rtdb.ref('.info/connected').on('value', async snapshot => {
if (snapshot.val() === false) return;
await userStatusDatabaseRef.onDisconnect().set(isOfflineForDatabase, () => {
onDisconnect();
});
await userStatusDatabaseRef.set(isOnlineForDatabase, () => {
onConnect();
});
});
}

export async function stopListenToOnlineStatus(rtdb: Database) {
rtdb.ref('.info/connected').off('value');
}

export async function callUpdateOnlineStatus(rtdb: Database, uid: string, gameId: string, state: 'online' | 'offline') {
const userStatusDatabaseRef = firebase.database().ref(`/${RTDBPaths.Statuses}/${uid}`);

const statusForDatabase = {
state,
updatedAt: firebase.database.ServerValue.TIMESTAMP,
gameId,
} as Status;

await userStatusDatabaseRef.set(statusForDatabase);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import axios from 'axios';

export default async function selectBestRTDBInstance(gameId: string): Promise<string> {
const res = await axios.get(`url?gameId=${gameId}`);
return res.data.bestRTDBInstanceId;
}
20 changes: 20 additions & 0 deletions packages/game-app/src/loadBalancer/sagas/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {
initializeRTDB,
pollUpdateStatusSagaWatcher,
startListenToOnlineStatusSaga,
stopListenToOnlineStatusSaga,
updateOnlineStatusSaga,
watchStatusChannel,
} from './updateOnlineStatus';
import { all } from 'redux-saga/effects';

export default function* loadBalancerSaga() {
yield all([
initializeRTDB(),
updateOnlineStatusSaga(),
startListenToOnlineStatusSaga(),
stopListenToOnlineStatusSaga(),
pollUpdateStatusSagaWatcher(),
watchStatusChannel(),
]);
}
98 changes: 98 additions & 0 deletions packages/game-app/src/loadBalancer/sagas/loadBalancer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { call, put, select, takeEvery, delay, take, race } from 'redux-saga/effects';
import { channel } from 'redux-saga';
import { actions, selectors as loadBalancerSelectors } from '../slice';
import { actions as loadGameActions, selectors as gameSelectors } from '../../gameView/slice';
import { addRequestStatusManagement } from '@pipeline/requests-status';
import {
callUpdateOnlineStatus,
startListenToOnlineStatus,
stopListenToOnlineStatus,
} from '../apis/callUpdateOnlineStatus';
import firebase from 'firebase';
import { AuthUser, selectors as authSelectors } from '@pipeline/auth';
import Database = firebase.database.Database;

const statusChannel = channel();

function* executeUpdateOnlineStatus(action: ReturnType<typeof actions.updateOnlineStatus>) {
const rtdb: Database = yield select(loadBalancerSelectors.getRTDB);
const user: AuthUser = yield select(authSelectors.getCurrentUser);
const gameId: string = yield select(gameSelectors.getSelectedGameId);
yield call(callUpdateOnlineStatus, rtdb, user.id, gameId, action.payload);
yield put(actions.updateOnlineStatusSuccess(action.payload));
}

export function* updateOnlineStatusSaga() {
yield takeEvery(
actions.updateOnlineStatus,
addRequestStatusManagement(executeUpdateOnlineStatus, 'loadBalancer.status'),
);
}

function* executeStartListenToOnlineStatus(action: ReturnType<typeof actions.startListenToOnlineStatus>) {
const rtdb: Database = yield select(loadBalancerSelectors.getRTDB);
const user: AuthUser = yield select(authSelectors.getCurrentUser);
const gameId: string = yield select(gameSelectors.getSelectedGameId);
yield call(
startListenToOnlineStatus,
rtdb,
user.id,
gameId,
() => {
statusChannel.put(actions.updateOnlineStatusSuccess('online'));
},
() => {
statusChannel.put(actions.updateOnlineStatusSuccess('offline'));
},
);
}

export function* startListenToOnlineStatusSaga() {
yield takeEvery(actions.startListenToOnlineStatus, executeStartListenToOnlineStatus);
}

function* executeStopListenToOnlineStatus(action: ReturnType<typeof actions.stopListenToOnlineStatus>) {
const rtdb: Database = yield select(loadBalancerSelectors.getRTDB);
yield call(stopListenToOnlineStatus, rtdb);
}

export function* stopListenToOnlineStatusSaga() {
yield takeEvery(actions.stopListenToOnlineStatus, executeStopListenToOnlineStatus);
}

function* pollUpdateStatusSagaWorker() {
while (true) {
const rtdb: Database = yield select(loadBalancerSelectors.getRTDB);
const user: AuthUser = yield select(authSelectors.getCurrentUser);
const gameId: string = yield select(gameSelectors.getSelectedGameId);
yield call(callUpdateOnlineStatus, rtdb, user.id, gameId, 'online');
yield put(actions.updateOnlineStatusSuccess('online'));
yield call(delay, 5000);
}
}

export function* pollUpdateStatusSagaWatcher() {
while (true) {
yield take(actions.startPollingOnlineStatus);
yield race([call(pollUpdateStatusSagaWorker), take(actions.stopPollingOnlineStatus)]);
}
}

export function* watchStatusChannel() {
while (true) {
const action = yield take(statusChannel);
yield put(action);
}
}

function* executeInitializeRTDB(action: ReturnType<typeof loadGameActions.saveGame>) {
const app = firebase.initializeApp({
databaseURL: `https://${action.payload.rtdbInstance}.firebaseio.com`,
});
const rtdb = app.database();
yield put(actions.updateRTDB(rtdb));
}

export function* initializeRTDB() {
yield takeEvery(loadGameActions.saveGame, executeInitializeRTDB);
}
62 changes: 62 additions & 0 deletions packages/game-app/src/loadBalancer/slice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { createAction, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';

import firebase from 'firebase/app';
import { Status } from '@pipeline/common';
import Timestamp = firebase.firestore.Timestamp;
import Database = firebase.database.Database;

export interface State {
status: Status | null;
rtdb: Database;
}

const initialState = {
status: null,
} as State;

const slice = createSlice({
name: 'loadBalancer',
initialState: initialState,
reducers: {
updateOnlineStatusSuccess(state, action: PayloadAction<'online' | 'offline'>) {
return {
...state,
status: {
state: action.payload,
updatedAt: Timestamp.now(),
},
};
},
updateRTDB(state, action: PayloadAction<Database>) {
return {
...state,
rtdb: action.payload,
};
},
},
});

const getSlice = createSelector(
(state: { [name]: State }) => state,
state => state[name],
);

const getRTDB = createSelector(getSlice, state => state.rtdb);

export const reducer = slice.reducer;
export const name = slice.name;

export const actions = {
...slice.actions,
updateOnlineStatus: createAction<'online' | 'offline'>(`${name}/startListenToOnlineStatus`),
startListenToOnlineStatus: createAction(`${name}/startListenToOnlineStatus`),
stopListenToOnlineStatus: createAction(`${name}/stopListenToOnlineStatus`),
startPollingOnlineStatus: createAction(`${name}/startPollingToOnlineStatus`),
stopPollingOnlineStatus: createAction(`${name}/stopPollingToOnlineStatus`),
};

export const selectors = {
getRTDBInstanceName,
getRTDBInstanceURL,
getRTDB,
};

0 comments on commit 2fcc81c

Please sign in to comment.