Skip to content

Commit 4f33aa7

Browse files
authored
Quiz exercises: Fix an issue with quiz participations on multi node environments (#12578)
1 parent 6c865b7 commit 4f33aa7

9 files changed

Lines changed: 355 additions & 18 deletions

File tree

src/main/java/de/tum/cit/aet/artemis/quiz/domain/DragAndDropSubmittedAnswer.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@
1111
import jakarta.persistence.JoinColumn;
1212
import jakarta.persistence.OneToMany;
1313

14-
import org.hibernate.annotations.Cache;
15-
import org.hibernate.annotations.CacheConcurrencyStrategy;
16-
1714
import com.fasterxml.jackson.annotation.JsonInclude;
1815

1916
import de.tum.cit.aet.artemis.quiz.domain.compare.DnDMapping;
@@ -26,10 +23,11 @@
2623
@JsonInclude(JsonInclude.Include.NON_EMPTY)
2724
public class DragAndDropSubmittedAnswer extends SubmittedAnswer {
2825

29-
// NOTE: this relation cannot be bidirectional, because it would otherwise be ManyToMany
26+
// NOTE: this relation cannot be bidirectional, because it would otherwise be ManyToMany.
27+
// No @Cache here on purpose — same reason as MultipleChoiceSubmittedAnswer.selectedOptions (see #12574): NONSTRICT_READ_WRITE on an
28+
// actively-mutated collection produced non-deterministic reads under concurrent autosave / evaluation activity on the clustered setup.
3029
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true)
3130
@JoinColumn(name = "submitted_answer_id")
32-
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
3331
private Set<DragAndDropMapping> mappings = new HashSet<>();
3432

3533
public Set<DragAndDropMapping> getMappings() {

src/main/java/de/tum/cit/aet/artemis/quiz/domain/MultipleChoiceSubmittedAnswer.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@
1212
import jakarta.persistence.JoinTable;
1313
import jakarta.persistence.ManyToMany;
1414

15-
import org.hibernate.annotations.Cache;
16-
import org.hibernate.annotations.CacheConcurrencyStrategy;
17-
1815
import com.fasterxml.jackson.annotation.JsonInclude;
1916

2017
import de.tum.cit.aet.artemis.core.domain.DomainObject;
@@ -27,8 +24,10 @@
2724
@JsonInclude(JsonInclude.Include.NON_EMPTY)
2825
public class MultipleChoiceSubmittedAnswer extends SubmittedAnswer {
2926

27+
// No @Cache here on purpose. A second-level cache with NONSTRICT_READ_WRITE used to produce partial / stale selected-option collections
28+
// under concurrent activity (autosave, evaluation) on a clustered setup, see Artemis issue #12574. Selected options are small and
29+
// rarely re-read for the same submission, so always fetching from the database is both simpler and deterministic.
3030
@ManyToMany(fetch = FetchType.EAGER)
31-
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
3231
@JoinTable(name = "multiple_choice_submitted_answer_selected_options", joinColumns = @JoinColumn(name = "multiple_choice_submitted_answers_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "selected_options_id", referencedColumnName = "id"))
3332
private Set<AnswerOption> selectedOptions = new HashSet<>();
3433

src/main/java/de/tum/cit/aet/artemis/quiz/domain/ShortAnswerSubmittedAnswer.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,6 @@
1212
import jakarta.persistence.OneToMany;
1313
import jakarta.validation.Valid;
1414

15-
import org.hibernate.annotations.Cache;
16-
import org.hibernate.annotations.CacheConcurrencyStrategy;
17-
1815
import com.fasterxml.jackson.annotation.JsonInclude;
1916

2017
import de.tum.cit.aet.artemis.quiz.domain.compare.SAMapping;
@@ -27,10 +24,11 @@
2724
@JsonInclude(JsonInclude.Include.NON_EMPTY)
2825
public class ShortAnswerSubmittedAnswer extends SubmittedAnswer {
2926

30-
// NOTE: this relation cannot be bidirectional, because it would otherwise be ManyToMany
27+
// NOTE: this relation cannot be bidirectional, because it would otherwise be ManyToMany.
28+
// No @Cache here on purpose — same reason as MultipleChoiceSubmittedAnswer.selectedOptions (see #12574): NONSTRICT_READ_WRITE on an
29+
// actively-mutated collection produced non-deterministic reads under concurrent autosave / evaluation activity on the clustered setup.
3130
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true)
3231
@JoinColumn(name = "submitted_answer_id")
33-
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
3432
@Valid
3533
private Set<ShortAnswerSubmittedText> submittedTexts = new HashSet<>();
3634

src/main/java/de/tum/cit/aet/artemis/quiz/repository/QuizSubmissionRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public interface QuizSubmissionRepository extends ArtemisJpaRepository<QuizSubmi
5151
List<QuizSubmission> findWithEagerSubmittedAnswersByParticipationId(long participationId);
5252

5353
@Query("""
54-
SELECT submission
54+
SELECT DISTINCT submission
5555
FROM QuizSubmission submission
5656
LEFT JOIN FETCH submission.submittedAnswers
5757
JOIN submission.results r

src/main/java/de/tum/cit/aet/artemis/quiz/service/QuizSubmissionService.java

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,13 +279,21 @@ public QuizSubmission saveSubmissionForLiveMode(Long exerciseId, QuizSubmission
279279
String logText = submitted ? "submit quiz in live mode:" : "save quiz in live mode:";
280280

281281
long start = System.nanoTime();
282-
var quizExercise = quizExerciseRepository.findByIdElseThrow(exerciseId);
282+
// Load questions eagerly so we can sanitize the client-submitted references below (answer options, drag items,
283+
// drop locations, short-answer spots). Without this, a stale id from the client causes Hibernate's merge to fail
284+
// the whole save with ObjectNotFoundException — see #12584.
285+
var quizExercise = quizExerciseRepository.findByIdWithQuestionsElseThrow(exerciseId);
283286
quizExercise.setQuizBatches(null);
284287
// A submission always exists because the user has to start the participation before submitting, which creates a submission
285288
var existingSubmission = quizSubmissionRepository.findByExerciseIdAndStudentLogin(quizExercise.getId(), userLogin)
286289
.orElseThrow(() -> new EntityNotFoundException("Cannot find quiz submission for exercise " + exerciseId + " and user " + userLogin));
287290
checkSubmissionForLiveModeOrThrow(quizExercise, existingSubmission, userLogin, logText, start);
288291

292+
// Replace client-supplied references with server-managed ones, and drop any that no longer exist. This protects
293+
// the downstream Hibernate merge from stale ids (e.g. after a quiz was re-imported) that would otherwise trigger
294+
// ObjectNotFoundException and abort the entire save.
295+
sanitizeSubmittedAnswersAgainstQuestions(quizSubmission, quizExercise);
296+
289297
// TODO: ideally we only save if something has changed, we can use "if (!isContentEqualTo(existingSubmission, quizSubmission))"
290298

291299
// recreate pointers back to submission in each submitted answer
@@ -369,6 +377,123 @@ private void checkSubmissionForLiveModeOrThrow(QuizExercise quizExercise, QuizSu
369377
// but for performance reasons the checks may have to be done in the quiz submission service where no feedback for the students can be generated
370378
}
371379

380+
/**
381+
* Replace every client-supplied reference inside the submitted answers (question, answer option, drag item, drop location, short-answer spot) with the server-side managed
382+
* instance loaded from {@code quizExercise}. Any reference that does not exist server-side is dropped. Submitted answers whose question is no longer part of the quiz are
383+
* removed entirely.
384+
*
385+
* <p>
386+
* This is a defensive sanitization: the live-submit endpoint still accepts a raw {@link QuizSubmission} from the client (see the TODO on
387+
* {@code QuizSubmissionResource.saveOrSubmitForLiveMode}). Without this step a single stale id — e.g. after a quiz was re-imported between tab open and submit — causes the
388+
* downstream Hibernate merge to abort the whole request with {@code ObjectNotFoundException} when it tries to resolve the reference (#12584).
389+
*/
390+
private void sanitizeSubmittedAnswersAgainstQuestions(QuizSubmission quizSubmission, QuizExercise quizExercise) {
391+
// Normalize a null collection to empty so downstream iterations (e.g. setSubmission on each answer) stay null-safe.
392+
if (quizSubmission.getSubmittedAnswers() == null) {
393+
quizSubmission.setSubmittedAnswers(new HashSet<>());
394+
return;
395+
}
396+
if (quizSubmission.getSubmittedAnswers().isEmpty()) {
397+
return;
398+
}
399+
Map<Long, QuizQuestion> questionsById = quizExercise.getQuizQuestions().stream().filter(question -> question.getId() != null)
400+
.collect(Collectors.toMap(QuizQuestion::getId, Function.identity()));
401+
Set<SubmittedAnswer> sanitizedAnswers = new HashSet<>();
402+
for (SubmittedAnswer submittedAnswer : quizSubmission.getSubmittedAnswers()) {
403+
QuizQuestion clientQuestion = submittedAnswer.getQuizQuestion();
404+
if (clientQuestion == null || clientQuestion.getId() == null) {
405+
continue;
406+
}
407+
QuizQuestion serverQuestion = questionsById.get(clientQuestion.getId());
408+
if (serverQuestion == null) {
409+
continue;
410+
}
411+
// Drop any submitted answer whose runtime subtype does not match the server-side question type — otherwise
412+
// we would attach e.g. an MC answer to a ShortAnswer question and pass an inconsistent entity to merge.
413+
if (submittedAnswer instanceof MultipleChoiceSubmittedAnswer mcAnswer && serverQuestion instanceof MultipleChoiceQuestion mcQuestion) {
414+
submittedAnswer.setQuizQuestion(serverQuestion);
415+
sanitizeMultipleChoiceSelectedOptions(mcAnswer, mcQuestion);
416+
sanitizedAnswers.add(submittedAnswer);
417+
}
418+
else if (submittedAnswer instanceof DragAndDropSubmittedAnswer dndAnswer && serverQuestion instanceof DragAndDropQuestion dndQuestion) {
419+
submittedAnswer.setQuizQuestion(serverQuestion);
420+
sanitizeDragAndDropMappings(dndAnswer, dndQuestion);
421+
sanitizedAnswers.add(submittedAnswer);
422+
}
423+
else if (submittedAnswer instanceof ShortAnswerSubmittedAnswer saAnswer && serverQuestion instanceof ShortAnswerQuestion saQuestion) {
424+
submittedAnswer.setQuizQuestion(serverQuestion);
425+
sanitizeShortAnswerSubmittedTexts(saAnswer, saQuestion);
426+
sanitizedAnswers.add(submittedAnswer);
427+
}
428+
}
429+
quizSubmission.setSubmittedAnswers(sanitizedAnswers);
430+
}
431+
432+
private void sanitizeMultipleChoiceSelectedOptions(MultipleChoiceSubmittedAnswer submittedAnswer, MultipleChoiceQuestion question) {
433+
Map<Long, AnswerOption> validOptions = question.getAnswerOptions().stream().filter(option -> option.getId() != null)
434+
.collect(Collectors.toMap(AnswerOption::getId, Function.identity()));
435+
Set<AnswerOption> clientOptions = submittedAnswer.getSelectedOptions();
436+
Set<AnswerOption> sanitized = new HashSet<>();
437+
if (clientOptions != null) {
438+
for (AnswerOption clientOption : clientOptions) {
439+
if (clientOption == null || clientOption.getId() == null) {
440+
continue;
441+
}
442+
AnswerOption serverOption = validOptions.get(clientOption.getId());
443+
if (serverOption != null) {
444+
sanitized.add(serverOption);
445+
}
446+
}
447+
}
448+
submittedAnswer.setSelectedOptions(sanitized);
449+
}
450+
451+
private void sanitizeDragAndDropMappings(DragAndDropSubmittedAnswer submittedAnswer, DragAndDropQuestion question) {
452+
Map<Long, DragItem> validDragItems = question.getDragItems().stream().filter(item -> item.getId() != null).collect(Collectors.toMap(DragItem::getId, Function.identity()));
453+
Map<Long, DropLocation> validDropLocations = question.getDropLocations().stream().filter(location -> location.getId() != null)
454+
.collect(Collectors.toMap(DropLocation::getId, Function.identity()));
455+
Set<DragAndDropMapping> clientMappings = submittedAnswer.getMappings();
456+
Set<DragAndDropMapping> sanitized = new HashSet<>();
457+
if (clientMappings != null) {
458+
for (DragAndDropMapping mapping : clientMappings) {
459+
if (mapping == null || mapping.getDragItem() == null || mapping.getDropLocation() == null || mapping.getDragItem().getId() == null
460+
|| mapping.getDropLocation().getId() == null) {
461+
continue;
462+
}
463+
DragItem serverDragItem = validDragItems.get(mapping.getDragItem().getId());
464+
DropLocation serverDropLocation = validDropLocations.get(mapping.getDropLocation().getId());
465+
if (serverDragItem == null || serverDropLocation == null) {
466+
continue;
467+
}
468+
mapping.setDragItem(serverDragItem);
469+
mapping.setDropLocation(serverDropLocation);
470+
sanitized.add(mapping);
471+
}
472+
}
473+
submittedAnswer.setMappings(sanitized);
474+
}
475+
476+
private void sanitizeShortAnswerSubmittedTexts(ShortAnswerSubmittedAnswer submittedAnswer, ShortAnswerQuestion question) {
477+
Map<Long, ShortAnswerSpot> validSpots = question.getSpots().stream().filter(spot -> spot.getId() != null)
478+
.collect(Collectors.toMap(ShortAnswerSpot::getId, Function.identity()));
479+
Set<ShortAnswerSubmittedText> clientTexts = submittedAnswer.getSubmittedTexts();
480+
Set<ShortAnswerSubmittedText> sanitized = new HashSet<>();
481+
if (clientTexts != null) {
482+
for (ShortAnswerSubmittedText submittedText : clientTexts) {
483+
if (submittedText == null || submittedText.getSpot() == null || submittedText.getSpot().getId() == null) {
484+
continue;
485+
}
486+
ShortAnswerSpot serverSpot = validSpots.get(submittedText.getSpot().getId());
487+
if (serverSpot == null) {
488+
continue;
489+
}
490+
submittedText.setSpot(serverSpot);
491+
sanitized.add(submittedText);
492+
}
493+
}
494+
submittedAnswer.setSubmittedTexts(sanitized);
495+
}
496+
372497
/**
373498
* Find StudentParticipation of the given quizExercise that was done by the given user
374499
*

src/main/webapp/app/quiz/manage/update/quiz-exercise-update.component.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,6 +1289,46 @@ describe('QuizExerciseUpdateComponent', () => {
12891289
expect(quizExerciseServiceCreateStub).toHaveBeenCalled();
12901290
});
12911291

1292+
it('should keep the edit screen visible after saving a not-yet-started quiz when the server response omits isEditable', () => {
1293+
// Server DTOs use @JsonInclude(NON_EMPTY), so create/update responses do not carry the isEditable flag.
1294+
// The component must recompute it client-side; otherwise the "quiz has started" banner appears incorrectly.
1295+
const savedResponse = new QuizExercise(course, undefined);
1296+
savedResponse.id = 456;
1297+
savedResponse.title = 'test';
1298+
savedResponse.duration = 600;
1299+
savedResponse.quizMode = QuizMode.SYNCHRONIZED;
1300+
savedResponse.status = QuizStatus.VISIBLE;
1301+
savedResponse.quizEnded = false;
1302+
savedResponse.quizBatches = [];
1303+
savedResponse.isAtLeastEditor = true;
1304+
savedResponse.isEditable = undefined;
1305+
quizExerciseServiceUpdateStub.mockReturnValue(of(new HttpResponse<QuizExercise>({ body: savedResponse })));
1306+
1307+
saveQuizWithPendingChangesCache();
1308+
1309+
expect(comp.quizExercise.isEditable).toBe(true);
1310+
});
1311+
1312+
it('should respect a server-provided isEditable=false flag after save (e.g. exam-date-aware)', () => {
1313+
// When the server authoritatively says the quiz is no longer editable (e.g. exam start date has passed),
1314+
// the client must not override it to true via the local check.
1315+
const savedResponse = new QuizExercise(course, undefined);
1316+
savedResponse.id = 456;
1317+
savedResponse.title = 'test';
1318+
savedResponse.duration = 600;
1319+
savedResponse.quizMode = QuizMode.SYNCHRONIZED;
1320+
savedResponse.status = QuizStatus.VISIBLE;
1321+
savedResponse.quizEnded = false;
1322+
savedResponse.quizBatches = [];
1323+
savedResponse.isAtLeastEditor = true;
1324+
savedResponse.isEditable = false;
1325+
quizExerciseServiceUpdateStub.mockReturnValue(of(new HttpResponse<QuizExercise>({ body: savedResponse })));
1326+
1327+
saveQuizWithPendingChangesCache();
1328+
1329+
expect(comp.quizExercise.isEditable).toBe(false);
1330+
});
1331+
12921332
it('should call alert service with specific error title if provided on save error', () => {
12931333
comp.quizExercise.id = undefined;
12941334
const mockErrorResponse = {

src/main/webapp/app/quiz/manage/update/quiz-exercise-update.component.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -598,8 +598,10 @@ export class QuizExerciseUpdateComponent extends QuizExerciseValidationDirective
598598
this.reconcileMappingReferences(quizExercise);
599599
this.prepareEntity(quizExercise);
600600
this.quizExercise = quizExercise;
601-
// Respect the server-provided isEditable flag (accounts for exam dates), combined with local check
602-
this.quizExercise.isEditable = this.quizExercise.isEditable && isQuizEditable(this.quizExercise);
601+
// Prefer the server-provided editability flag (e.g. exam-date-aware) when present; otherwise fall back to the
602+
// local check. The create/update endpoints currently omit the field, which is why the unconditional overwrite
603+
// by the previous implementation flipped the banner on for fresh, not-yet-started quizzes.
604+
this.quizExercise.isEditable = this.quizExercise.isEditable ?? isQuizEditable(this.quizExercise);
603605
this.exerciseService.validateDate(this.quizExercise);
604606
this.savedEntity = cloneDeep(this.quizExercise);
605607
this.changeDetector.detectChanges();

0 commit comments

Comments
 (0)