From 587344a39c684fab727e6953735ec5461fda041f Mon Sep 17 00:00:00 2001 From: Eric Date: Fri, 10 Jul 2026 13:04:41 -0500 Subject: [PATCH] fix: fix transcript audio error when paused --- .../port/DiscoverySessionRepository.java | 18 ++++++++++++++++ .../service/RealtimeSuggestionService.java | 8 ++++--- .../DiscoverySessionRepositoryAdapter.java | 6 ++++++ .../DiscoverySessionJpaRepository.java | 21 +++++++++++++++++++ .../RealtimeSuggestionServiceTest.java | 10 +++++---- 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/kntro/reqsai/discovery/application/port/DiscoverySessionRepository.java b/src/main/java/com/kntro/reqsai/discovery/application/port/DiscoverySessionRepository.java index d185a3e9..496790d8 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/port/DiscoverySessionRepository.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/port/DiscoverySessionRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.time.Instant; import java.util.Optional; import java.util.UUID; @@ -25,4 +26,21 @@ public interface DiscoverySessionRepository { * enforce the single-active-session rule before starting or resuming another. */ Optional findActiveByProjectId(UUID projectId); + + /** + * Atomically advances the realtime-suggestion watermark and cadence timestamp with a scoped UPDATE, + * without rewriting the rest of the aggregate. + *

+ * Critical for correctness: the realtime-suggestion pass loads the session, spends seconds in the + * LLM call, then must persist only the watermark. Saving the whole aggregate would carry a stale + * {@code last_sequence} and clobber the value the concurrent transcript-append path advanced in the + * meantime — corrupting the segment sequence and breaking live transcription (duplicate-key on + * {@code (session_id, sequence)}). This targeted update never touches {@code last_sequence}. The + * watermark never moves backwards. + * + * @param sessionId the session to update + * @param suggestedSequence the new watermark (highest final segment turned into suggestions) + * @param suggestedAt the instant of this suggestion pass + */ + void advanceSuggestionWatermark(UUID sessionId, int suggestedSequence, Instant suggestedAt); } diff --git a/src/main/java/com/kntro/reqsai/discovery/application/service/RealtimeSuggestionService.java b/src/main/java/com/kntro/reqsai/discovery/application/service/RealtimeSuggestionService.java index ad1fadd0..1b4e768c 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/service/RealtimeSuggestionService.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/service/RealtimeSuggestionService.java @@ -168,9 +168,11 @@ public void suggest(UUID sessionId, boolean force) { List created = suggestionCreation.createSuggestions(result, sessionId, session.getProjectId()); - session.advanceSuggestedSequence(maxSequence); - session.markSuggestedAt(Instant.now()); - sessions.save(session); + // Persist ONLY the watermark + cadence timestamp with a scoped UPDATE. Do NOT mutate + save the + // whole aggregate here: this pass loaded the session seconds ago (before the LLM call), so a full + // save would carry a stale last_sequence and clobber the value the concurrent transcript-append + // path advanced meanwhile — corrupting the segment sequence and freezing live transcription. + sessions.advanceSuggestionWatermark(sessionId, maxSequence, Instant.now()); log.info("Realtime suggestion for session {}: {} suggestions from {} segments (watermark {} -> {}, force={})", sessionId, created.size(), pending.size(), watermark, maxSequence, force); } diff --git a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/DiscoverySessionRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/DiscoverySessionRepositoryAdapter.java index 6d5ade8e..1baa3d94 100644 --- a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/DiscoverySessionRepositoryAdapter.java +++ b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/DiscoverySessionRepositoryAdapter.java @@ -9,6 +9,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -40,4 +41,9 @@ public Optional findActiveByProjectId(UUID projectId) { return jpa.findFirstByProjectIdAndStatusIn( projectId, List.of(SessionStatus.RECORDING, SessionStatus.PAUSED)); } + + @Override + public void advanceSuggestionWatermark(UUID sessionId, int suggestedSequence, Instant suggestedAt) { + jpa.advanceSuggestionWatermark(sessionId, suggestedSequence, suggestedAt); + } } diff --git a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/DiscoverySessionJpaRepository.java b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/DiscoverySessionJpaRepository.java index 0e84a49d..3397024d 100644 --- a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/DiscoverySessionJpaRepository.java +++ b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/DiscoverySessionJpaRepository.java @@ -5,7 +5,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -17,4 +21,21 @@ public interface DiscoverySessionJpaRepository extends JpaRepository findFirstByProjectIdAndStatusIn(UUID projectId, List statuses); + + /** + * Scoped watermark update — touches ONLY the two suggestion columns so it can never clobber + * {@code lastSequence} (advanced by the concurrent transcript-append path). The + * {@code lastSuggestedSequence < :sequence} guard keeps the watermark monotonic under overlap. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update DiscoverySession s + set s.lastSuggestedSequence = :sequence, + s.lastSuggestedAt = :suggestedAt + where s.id = :sessionId + and s.lastSuggestedSequence < :sequence + """) + int advanceSuggestionWatermark(@Param("sessionId") UUID sessionId, + @Param("sequence") int sequence, + @Param("suggestedAt") Instant suggestedAt); } diff --git a/src/test/java/com/kntro/reqsai/discovery/application/service/RealtimeSuggestionServiceTest.java b/src/test/java/com/kntro/reqsai/discovery/application/service/RealtimeSuggestionServiceTest.java index 3da0edf5..5258ed96 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/service/RealtimeSuggestionServiceTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/service/RealtimeSuggestionServiceTest.java @@ -107,8 +107,10 @@ void should_use_similarity_search_when_embedding_available() { verify(generation).generate(any(), eq("es-PE"), contextCaptor.capture()); assertThat(contextCaptor.getValue().projectName()).isEqualTo("PayApp"); assertThat(contextCaptor.getValue().constraints()).containsExactly("PCI-DSS compliant"); - verify(sessions).save(session); - assertThat(session.getLastSuggestedSequence()).isEqualTo(5); + // Watermark persisted via the scoped update (not a full-aggregate save) so it never + // clobbers last_sequence advanced by the concurrent transcript-append path. + verify(sessions).advanceSuggestionWatermark(eq(sessionId), eq(5), any()); + verify(sessions, never()).save(any()); } @Test @@ -435,7 +437,7 @@ void should_generate_when_time_fallback_elapsed() { service.suggest(session.getId()); verify(generation).generate(any(), any(), any()); - assertThat(session.getLastSuggestedSequence()).isEqualTo(7); + verify(sessions).advanceSuggestionWatermark(eq(session.getId()), eq(7), any()); } @Test @@ -471,7 +473,7 @@ void should_generate_when_char_threshold_crossed() { service.suggest(session.getId()); verify(generation).generate(any(), any(), any()); - assertThat(session.getLastSuggestedAt()).isNotNull(); + verify(sessions).advanceSuggestionWatermark(eq(session.getId()), eq(3), any()); } @Test