Skip to content

Commit 0786248

Browse files
authored
Merge pull request #45 from asm0dey/fix/deck-close-activate-race
fix(deck): atomic conditional close prevents clobbering next slide's activate
2 parents b7281a3 + 20fb5dc commit 0786248

8 files changed

Lines changed: 84 additions & 10 deletions

File tree

src/main/java/site/asm0dey/slidev/polls/core/service/PollRepository.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ public interface PollRepository {
5858
/** Close the currently-ACTIVE question on {@code pollId}. No-op when none is active. */
5959
Poll closeActiveQuestion(UUID pollId);
6060

61+
/**
62+
* Conditional close: close the currently-ACTIVE question on {@code pollId} only when {@code
63+
* expectedQuestionId} is null, or matches the question that is active. The guard is applied
64+
* atomically under the poll-row lock, so a slide-leave close scoped to the question its slide
65+
* opened cannot clobber a concurrent next-slide activate that already moved the active question
66+
* on. No-op when none is active or the active question differs from {@code expectedQuestionId}.
67+
*/
68+
Poll closeActiveQuestion(UUID pollId, UUID expectedQuestionId);
69+
6170
/**
6271
* Replace the allowed-origins list for {@code pollId}. A non-null (even empty) list replaces the
6372
* current value. Throws {@link site.asm0dey.slidev.polls.core.error.NotFoundException} when the

src/main/java/site/asm0dey/slidev/polls/core/service/PollService.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -384,11 +384,15 @@ public Poll closeActiveQuestion(UUID pollId, UUID expectedQuestionId) {
384384
Poll before =
385385
repository.findById(pollId).orElseThrow(() -> new NotFoundException(pollId.toString()));
386386
UUID wasActive = before.activeQuestionId();
387-
if (expectedQuestionId != null && !expectedQuestionId.equals(wasActive)) {
388-
return before;
389-
}
390-
Poll after = repository.closeActiveQuestion(pollId);
391-
if (wasActive != null) {
387+
// The expectedQuestionId guard is applied atomically inside the repository under the poll-row
388+
// lock, so this unlocked pre-read cannot let a slide-leave close clobber a concurrent
389+
// next-slide activate: if the active question has moved on, the conditional UPDATE matches no
390+
// row and `after` still reports the newer active question.
391+
Poll after = repository.closeActiveQuestion(pollId, expectedQuestionId);
392+
// Emit the closed event only when a question actually transitioned ACTIVE -> none. When the
393+
// guard no-ops, `after` keeps its active question, so a stale `wasActive` cannot produce a
394+
// spurious event.
395+
if (wasActive != null && after.activeQuestionId() == null) {
392396
events.publishEvent(new PollQuestionClosedEvent(pollId, wasActive, Instant.now()));
393397
}
394398
return after;

src/main/java/site/asm0dey/slidev/polls/persistence/PollRepositoryImpl.java

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import java.util.Map;
1818
import java.util.Optional;
1919
import java.util.UUID;
20+
import org.jooq.Condition;
2021
import org.jooq.DSLContext;
2122
import org.jooq.Field;
2223
import org.jooq.Record;
@@ -342,24 +343,40 @@ public Poll activateQuestion(UUID pollId, UUID questionId) {
342343

343344
@Override
344345
public Poll closeActiveQuestion(UUID pollId) {
346+
return closeActiveQuestion(pollId, null);
347+
}
348+
349+
@Override
350+
public Poll closeActiveQuestion(UUID pollId, UUID expectedQuestionId) {
345351
// Same serialisation reason as activateQuestion — keeps concurrent close + activate on
346352
// the same poll from interleaving their per-row locks on poll_questions.
353+
//
354+
// The expectedQuestionId guard is evaluated INSIDE the poll-row lock: the deck fires a
355+
// slide-leave close scoped to the question that slide opened, and the next slide's activate
356+
// serialises behind the same lock. Folding the guard into the UPDATE's WHERE (rather than a
357+
// prior unlocked read in the service) makes the close a true no-op when the active question
358+
// has already moved on, so it can never clobber the newer activation (the source of the
359+
// deck slide-switch flake / a real presenter-facing race on rapid navigation).
347360
return dsl.transactionResult(
348361
cfg -> {
349362
DSLContext tx = cfg.dsl();
350363
lockPollRow(tx, pollId);
351364
OffsetDateTime now = OffsetDateTime.now();
365+
Condition where =
366+
POLL_QUESTIONS
367+
.POLL_ID
368+
.eq(pollId)
369+
.and(POLL_QUESTIONS.STATUS.eq(QuestionStatus.ACTIVE.name()));
370+
if (expectedQuestionId != null) {
371+
where = where.and(POLL_QUESTIONS.ID.eq(expectedQuestionId));
372+
}
352373
// poll_questions_active_timestamp_ck demands activated_at be NULL when status is not
353374
// ACTIVE.
354375
tx.update(POLL_QUESTIONS)
355376
.set(POLL_QUESTIONS.STATUS, QuestionStatus.CLOSED.name())
356377
.setNull(POLL_QUESTIONS.ACTIVATED_AT)
357378
.set(POLL_QUESTIONS.CLOSED_AT, now)
358-
.where(
359-
POLL_QUESTIONS
360-
.POLL_ID
361-
.eq(pollId)
362-
.and(POLL_QUESTIONS.STATUS.eq(QuestionStatus.ACTIVE.name())))
379+
.where(where)
363380
.execute();
364381
return findById(tx, pollId).orElseThrow(() -> new NotFoundException(pollId.toString()));
365382
});

src/test/java/site/asm0dey/slidev/polls/core/service/PollServiceLockTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ public Poll closeActiveQuestion(UUID pollId) {
302302
throw new UnsupportedOperationException();
303303
}
304304

305+
@Override
306+
public Poll closeActiveQuestion(UUID pollId, UUID expectedQuestionId) {
307+
throw new UnsupportedOperationException();
308+
}
309+
305310
@Override
306311
public Poll updateAllowedOrigins(UUID pollId, List<String> origins) {
307312
Poll existing = require(pollId);

src/test/java/site/asm0dey/slidev/polls/core/service/PollServiceTest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,15 @@ public Poll closeActiveQuestion(UUID pollId) {
621621
return after;
622622
}
623623

624+
@Override
625+
public Poll closeActiveQuestion(UUID pollId, UUID expectedQuestionId) {
626+
Poll existing = requirePresent(pollId);
627+
if (expectedQuestionId != null && !expectedQuestionId.equals(existing.activeQuestionId())) {
628+
return existing;
629+
}
630+
return closeActiveQuestion(pollId);
631+
}
632+
624633
@Override
625634
public Poll updateAllowedOrigins(UUID pollId, List<String> origins) {
626635
Poll existing = requirePresent(pollId);

src/test/java/site/asm0dey/slidev/polls/core/service/VoteServiceTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ public Poll closeActiveQuestion(UUID pollId) {
335335
throw new UnsupportedOperationException("not needed for VoteServiceTest");
336336
}
337337

338+
@Override
339+
public Poll closeActiveQuestion(UUID pollId, UUID expectedQuestionId) {
340+
throw new UnsupportedOperationException("not needed for VoteServiceTest");
341+
}
342+
338343
@Override
339344
public Poll updateAllowedOrigins(UUID pollId, java.util.List<String> origins) {
340345
throw new UnsupportedOperationException("not needed for VoteServiceTest");

src/test/java/site/asm0dey/slidev/polls/persistence/PollRepositoryImplTest.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,26 @@ void deletePollCascadesQuestionsWithoutTriggerError() {
216216
assertThat(repo.findById(inserted.id())).isEmpty();
217217
}
218218

219+
@Test
220+
void closeActiveQuestion_withExpectedMismatch_isNoOpAndKeepsActive() {
221+
Poll poll =
222+
insertPollWithQuestions(
223+
"close-guard",
224+
new QuestionSeed("Q1?", List.of("A", "B")),
225+
new QuestionSeed("Q2?", List.of("A", "B")));
226+
UUID q1 = poll.questions().getFirst().id();
227+
UUID q2 = poll.questions().getLast().id();
228+
229+
assertThat(repo.activateQuestion(poll.id(), q2).activeQuestionId()).isEqualTo(q2);
230+
231+
// A slide-leave close scoped to Q1 must NOT clobber the active Q2 — this is the atomic
232+
// guard that fixes the deck slide-switch race (close racing past the next slide's activate).
233+
assertThat(repo.closeActiveQuestion(poll.id(), q1).activeQuestionId()).isEqualTo(q2);
234+
235+
// A close scoped to the actually-active Q2 closes it.
236+
assertThat(repo.closeActiveQuestion(poll.id(), q2).activeQuestionId()).isNull();
237+
}
238+
219239
private record QuestionSeed(String prompt, List<String> optionLabels) {}
220240

221241
private Poll insertPollWithQuestions(String slugSuffix, QuestionSeed... seeds) {

src/test/java/site/asm0dey/slidev/polls/realtime/sse/TallyBroadcastTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,11 @@ public Poll closeActiveQuestion(UUID pollId) {
395395
throw new UnsupportedOperationException();
396396
}
397397

398+
@Override
399+
public Poll closeActiveQuestion(UUID pollId, UUID expectedQuestionId) {
400+
throw new UnsupportedOperationException();
401+
}
402+
398403
@Override
399404
public Poll updateAllowedOrigins(UUID pollId, java.util.List<String> origins) {
400405
throw new UnsupportedOperationException();

0 commit comments

Comments
 (0)