Skip to content

Commit 7f543a8

Browse files
committed
fix postgres lost update problem
1 parent 19a644f commit 7f543a8

2 files changed

Lines changed: 120 additions & 8 deletions

File tree

src/postgres.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,20 +102,30 @@ export class PostgresStore extends Async {
102102
deltalog?: LogEntry[]
103103
): Promise<void> {
104104
await this._sequelize.transaction(async (transaction) => {
105-
// 1. get previous state
105+
// Lock the row to avoid lost updates when multiple setState calls race.
106106
const match: Match | null = await Match.findByPk(id, {
107107
transaction,
108+
lock: transaction.LOCK.UPDATE,
108109
});
109-
const previousState = match?.state;
110-
// 2. check if given state is newer than previous, otherwise skip
111-
if (!previousState || previousState._stateID < state._stateID) {
112-
await Match.upsert(
110+
111+
if (!match) {
112+
await Match.create(
113113
{
114114
id,
115-
// 3. set new state
116115
state,
117-
// 4. append deltalog to log if provided
118-
log: [...(match?.log ?? []), ...(deltalog ?? [])],
116+
log: deltalog ?? [],
117+
},
118+
{ transaction }
119+
);
120+
return;
121+
}
122+
123+
const previousState = match.state;
124+
if (!previousState || previousState._stateID < state._stateID) {
125+
await match.update(
126+
{
127+
state,
128+
log: [...(match.log ?? []), ...(deltalog ?? [])],
119129
},
120130
{ transaction }
121131
);

test/src/concurrent-set-state.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,106 @@ describe("concurrent setState", () => {
147147
expect.arrayContaining([expect.objectContaining({ _stateID: 3 })])
148148
);
149149
});
150+
151+
it("should expose lost update when stale write commits last", async () => {
152+
await Match.create(match);
153+
154+
const stateA: State = {
155+
...state,
156+
ctx: { ...state.ctx, currentPlayer: "102", turn: 2 },
157+
_stateID: 2,
158+
};
159+
const stateB: State = {
160+
...state,
161+
ctx: { ...state.ctx, currentPlayer: "103", turn: 3 },
162+
_stateID: 3,
163+
};
164+
165+
const logA: LogEntry[] = [
166+
{
167+
...logEntry,
168+
_stateID: 2,
169+
turn: 2,
170+
action: {
171+
...logEntry.action,
172+
payload: { ...logEntry.action.payload, playerID: "102" },
173+
},
174+
},
175+
];
176+
const logB: LogEntry[] = [
177+
{
178+
...logEntry,
179+
_stateID: 3,
180+
turn: 3,
181+
action: {
182+
...logEntry.action,
183+
payload: { ...logEntry.action.payload, playerID: "103" },
184+
},
185+
},
186+
];
187+
188+
const originalFindByPk = Match.findByPk.bind(Match);
189+
const originalUpsert = Match.upsert.bind(Match);
190+
191+
let findByPkCount = 0;
192+
let releaseReads!: () => void;
193+
const readsReady = new Promise<void>((resolve) => {
194+
releaseReads = resolve;
195+
});
196+
197+
let releaseStateAWrite!: () => void;
198+
const stateAWriteGate = new Promise<void>((resolve) => {
199+
releaseStateAWrite = resolve;
200+
});
201+
202+
const findByPkSpy = jest
203+
.spyOn(Match, "findByPk")
204+
.mockImplementation(async (...args: Parameters<typeof Match.findByPk>) => {
205+
const row = await originalFindByPk(...args);
206+
findByPkCount += 1;
207+
if (findByPkCount === 2) {
208+
releaseReads();
209+
}
210+
await readsReady;
211+
return row;
212+
});
213+
214+
const upsertSpy = jest
215+
.spyOn(Match, "upsert")
216+
.mockImplementation(async (...args: Parameters<typeof Match.upsert>) => {
217+
const values = args[0] as { state?: State };
218+
const incomingStateID = values.state?._stateID;
219+
220+
if (incomingStateID === 2) {
221+
await stateAWriteGate;
222+
}
223+
224+
const result = await originalUpsert(...args);
225+
226+
if (incomingStateID === 3) {
227+
releaseStateAWrite();
228+
}
229+
230+
return result;
231+
});
232+
233+
try {
234+
await Promise.all([
235+
testStore.db.setState(match.id!, stateA, logA),
236+
testStore.db.setState(match.id!, stateB, logB),
237+
]);
238+
} finally {
239+
findByPkSpy.mockRestore();
240+
upsertSpy.mockRestore();
241+
}
242+
243+
const result = await testStore.db.fetch(match.id!, {
244+
state: true,
245+
log: true,
246+
});
247+
248+
// Correct behavior would keep the highest _stateID even if stale write runs last.
249+
// This expectation is intentionally red until setState is fixed.
250+
expect(result.state!._stateID).toBe(3);
251+
});
150252
});

0 commit comments

Comments
 (0)