Skip to content

Commit 63c3cdf

Browse files
committed
mongo race condition checks
1 parent 65cefdf commit 63c3cdf

5 files changed

Lines changed: 86 additions & 11 deletions

File tree

src/client/multiplayer/multiplayer.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ export class Multiplayer {
8585
}
8686

8787
this.socket.on('sync', (gameID, state) => {
88-
if (gameID == this.gameID) {
88+
if (
89+
gameID == this.gameID &&
90+
state._stateID >= this.store.getState()._stateID
91+
) {
8992
const action = ActionCreators.restore(state);
9093
action._remote = true;
9194
this.store.dispatch(action);

src/client/multiplayer/multiplayer.test.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,15 @@ test('multiplayer', () => {
8282

8383
// sync restores state.
8484
const restored = { restore: true };
85-
expect(store.getState()).not.toEqual(restored);
85+
expect(store.getState()).not.toMatchObject(restored);
8686
mockSocket.receive('sync', 'unknown gameID', restored);
87-
expect(store.getState()).not.toEqual(restored);
87+
expect(store.getState()).not.toMatchObject(restored);
8888
mockSocket.receive('sync', 'default:default', restored);
89-
expect(store.getState()).toEqual(restored);
89+
expect(store.getState()).not.toMatchObject(restored);
90+
// Only if the stateID is not stale.
91+
restored._stateID = 1;
92+
mockSocket.receive('sync', 'default:default', restored);
93+
expect(store.getState()).toMatchObject(restored);
9094

9195
// updateGameID causes a sync.
9296
mockSocket.emit = jest.fn();

src/server/db.js

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,28 @@ export class Mongo {
8989
* @param {object} store - A game state to persist.
9090
*/
9191
async set(id, state) {
92+
// Don't set a value if the cache has a more recent version.
93+
// This can occur due a race condition.
94+
//
95+
// For example:
96+
//
97+
// A --sync--> server | DB => 0 --+
98+
// |
99+
// A <--sync-- server | DB => 0 --+
100+
//
101+
// B --sync--> server | DB => 0 ----+
102+
// |
103+
// A --move--> server | DB <= 1 --+ |
104+
// | |
105+
// A <--sync-- server | DB => 1 --+ |
106+
// |
107+
// B <--sync-- server | DB => 0 ----+
108+
//
109+
const cacheValue = this.cache.get(id);
110+
if (cacheValue && cacheValue._stateID >= state._stateID) {
111+
return;
112+
}
113+
92114
this.cache.set(id, state);
93115

94116
const col = this.db.collection(id);
@@ -105,9 +127,9 @@ export class Mongo {
105127
* if no game is found with this id.
106128
*/
107129
async get(id) {
108-
const item = this.cache.get(id);
109-
if (item !== undefined) {
110-
return item;
130+
let cacheValue = this.cache.get(id);
131+
if (cacheValue !== undefined) {
132+
return cacheValue;
111133
}
112134

113135
const col = this.db.collection(id);
@@ -117,7 +139,27 @@ export class Mongo {
117139
.limit(1)
118140
.toArray();
119141

120-
this.cache.set(id, docs[0]);
142+
let oldStateID = 0;
143+
cacheValue = this.cache.get(id);
144+
/* istanbul ignore next line */
145+
if (cacheValue !== undefined) {
146+
/* istanbul ignore next line */
147+
oldStateID = cacheValue._stateID;
148+
}
149+
150+
let newStateID = -1;
151+
if (docs.length > 0) {
152+
newStateID = docs[0]._stateID;
153+
}
154+
155+
// Update the cache, but only if the read
156+
// value is newer than the value already in it.
157+
// A race condition might overwrite the
158+
// cache with an older value, so we need this.
159+
if (newStateID >= oldStateID) {
160+
this.cache.set(id, docs[0]);
161+
}
162+
121163
return docs[0];
122164
}
123165

@@ -127,8 +169,8 @@ export class Mongo {
127169
* @returns {boolean} - True if a game with this id exists.
128170
*/
129171
async has(id) {
130-
const item = this.cache.get(id);
131-
if (item !== undefined) {
172+
const cacheValue = this.cache.get(id);
173+
if (cacheValue !== undefined) {
132174
return true;
133175
}
134176

src/server/db.test.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,26 @@ test('Mongo', async () => {
8181
expect(db.dbname).toBe('test');
8282
}
8383
});
84+
85+
test('Mongo - race conditions', async () => {
86+
const mockClient = MongoDB.MongoClient;
87+
const db = new Mongo({ mockClient, url: 'a' });
88+
await db.connect();
89+
90+
// Out of order set()'s.
91+
await db.set('gameID', { _stateID: 1 });
92+
await db.set('gameID', { _stateID: 0 });
93+
expect(await db.get('gameID')).toEqual({ _stateID: 1 });
94+
95+
// Do not override cache on get() if it is fresher than Mongo.
96+
await db.set('gameID', { _stateID: 0 });
97+
db.cache.set('gameID', { _stateID: 1 });
98+
await db.get('gameID');
99+
expect(db.cache.get('gameID')).toEqual({ _stateID: 1 });
100+
101+
// Override if it is staler than Mongo.
102+
await db.set('gameID', { _stateID: 1 });
103+
db.cache.reset();
104+
expect(await db.get('gameID')).toMatchObject({ _stateID: 1 });
105+
expect(db.cache.get('gameID')).toMatchObject({ _stateID: 1 });
106+
});

src/server/index.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@ export function Server({ games, db, _clientInfo, _roomInfo }) {
8989
}
9090
}
9191

92-
db.set(gameID, store.getState());
92+
await db.set(gameID, store.getState());
9393
}
94+
95+
return;
9496
});
9597

9698
socket.on('sync', async (gameID, playerID, numPlayers) => {
@@ -106,6 +108,7 @@ export function Server({ games, db, _clientInfo, _roomInfo }) {
106108
clientInfo.set(socket.id, { gameID, playerID });
107109

108110
let state = await db.get(gameID);
111+
109112
if (state === undefined) {
110113
const store = Redux.createStore(reducer);
111114
state = store.getState();

0 commit comments

Comments
 (0)