Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -25,4 +26,21 @@ public interface DiscoverySessionRepository {
* enforce the single-active-session rule before starting or resuming another.
*/
Optional<DiscoverySession> findActiveByProjectId(UUID projectId);

/**
* Atomically advances the realtime-suggestion watermark and cadence timestamp with a scoped UPDATE,
* without rewriting the rest of the aggregate.
* <p>
* 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,11 @@ public void suggest(UUID sessionId, boolean force) {

List<Suggestion> 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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -40,4 +41,9 @@ public Optional<DiscoverySession> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,4 +21,21 @@ public interface DiscoverySessionJpaRepository extends JpaRepository<DiscoverySe

/** The project's active session (at most one — enforced by the partial unique index). */
Optional<DiscoverySession> findFirstByProjectIdAndStatusIn(UUID projectId, List<SessionStatus> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading