From 74bad000e3a2d0e069228e9f4a766d1e24433357 Mon Sep 17 00:00:00 2001 From: shychee Date: Tue, 21 Jul 2026 15:58:32 +0800 Subject: [PATCH 1/2] fix(search): rebuild search index asynchronously after label change Attaching or detaching a skill label triggers a search index rebuild via an afterCommit callback. Because LabelSearchSyncService.rebuildSkill ran synchronously on the request thread, the @Transactional index write executed inside the already-committed transaction-synchronization phase and was silently dropped -- the search document was never written, so label keywords never became searchable. Move rebuildSkill onto the skillhubEventExecutor with @Async (matching the existing rebuildSkills batch path) so the rebuild runs on a fresh thread and transaction. Add an integration test that fails on the old synchronous path and passes with the async fix. Signed-off-by: shychee --- .../service/LabelSearchSyncService.java | 7 +- .../LabelSearchSyncIntegrationTest.java | 137 ++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelSearchSyncService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelSearchSyncService.java index 4920402d..0dc9ce41 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelSearchSyncService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/LabelSearchSyncService.java @@ -20,8 +20,13 @@ public class LabelSearchSyncService { this.searchRebuildService = searchRebuildService; } + @Async("skillhubEventExecutor") public void rebuildSkill(Long skillId) { - searchRebuildService.rebuildBySkill(skillId); + try { + searchRebuildService.rebuildBySkill(skillId); + } catch (RuntimeException ex) { + log.error("Failed to rebuild search document for skill {}", skillId, ex); + } } @Async("skillhubEventExecutor") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java new file mode 100644 index 00000000..c7b9dd3a --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java @@ -0,0 +1,137 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.SkillhubApplication; +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.domain.label.LabelDefinition; +import com.iflytek.skillhub.domain.label.LabelDefinitionRepository; +import com.iflytek.skillhub.domain.label.LabelTranslation; +import com.iflytek.skillhub.domain.label.LabelTranslationRepository; +import com.iflytek.skillhub.domain.label.LabelType; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; +import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; +import com.iflytek.skillhub.search.SearchEmbeddingService; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * Reproduces the bug where attaching a skill label does not update the search + * index. The label keyword should appear in the rebuilt search document after + * {@code attachLabel} commits. + * + *

With the upstream (synchronous) {@code LabelSearchSyncService.rebuildSkill}, + * the rebuild runs inside the {@code afterCommit} callback on the request thread, + * where the {@code @Transactional index()} write does not persist — so the keyword + * never lands in the index and this test fails. Adding {@code @Async} moves the + * rebuild to a fresh thread/transaction and the keyword appears. + */ +@SpringBootTest(classes = SkillhubApplication.class) +@ActiveProfiles("test") +@Import(TestRedisConfig.class) +class LabelSearchSyncIntegrationTest { + + @Autowired + private SkillLabelAppService skillLabelAppService; + + @Autowired + private NamespaceRepository namespaceRepository; + + @Autowired + private SkillRepository skillRepository; + + @Autowired + private LabelDefinitionRepository labelDefinitionRepository; + + @Autowired + private LabelTranslationRepository labelTranslationRepository; + + @Autowired + private SkillSearchDocumentJpaRepository skillSearchDocumentJpaRepository; + + @MockBean + private SearchEmbeddingService searchEmbeddingService; + + @BeforeEach + void setUp() { + when(searchEmbeddingService.embed(anyString())).thenReturn(""); + when(searchEmbeddingService.similarity(anyString(), anyString())).thenReturn(0.0d); + } + + @Test + void attachingLabel_updatesSearchIndexWithLabelKeyword() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String ownerId = "owner-" + suffix; + // ASCII display name so the tokenizer keeps it as a single searchable token. + String labelDisplayName = "MachineLearning" + suffix; + String labelSlug = "ml-" + suffix; + + Namespace namespace = new Namespace("ns-" + suffix, "NS " + suffix, ownerId); + namespace.setType(NamespaceType.GLOBAL); + namespace = namespaceRepository.save(namespace); + + Skill skill = new Skill(namespace.getId(), "skill-" + suffix, ownerId, SkillVisibility.PUBLIC); + skill.setDisplayName("Skill " + suffix); + skill.setSummary("A skill used to reproduce the label search sync bug."); + skill.setCreatedBy(ownerId); + skill.setUpdatedBy(ownerId); + skill = skillRepository.save(skill); + skillRepository.flush(); + + LabelDefinition label = labelDefinitionRepository.save( + new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId)); + labelTranslationRepository.saveAll(List.of( + new LabelTranslation(label.getId(), "en", labelDisplayName))); + labelTranslationRepository.flush(); + + // Baseline: nothing indexed yet. + assertThat(skillSearchDocumentJpaRepository.findBySkillId(skill.getId())).isEmpty(); + + // Act: attach the label as the skill owner (passes resolve + permission checks). + Map ownerRoles = Map.of(namespace.getId(), NamespaceRole.OWNER); + skillLabelAppService.attachLabel( + namespace.getSlug(), + skill.getSlug(), + labelSlug, + ownerId, + ownerRoles, + new AuditRequestContext("127.0.0.1", "junit")); + + // Assert: the rebuilt search document must contain the label keyword. + SkillSearchDocumentEntity indexed = awaitIndexedDocument(skill.getId()); + assertThat(indexed.getKeywords()) + .as("label keyword should be indexed after attachLabel commits") + .contains(labelDisplayName); + } + + private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException { + Instant deadline = Instant.now().plus(Duration.ofSeconds(15)); + Optional indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); + while (indexed.isEmpty() && Instant.now().isBefore(deadline)) { + Thread.sleep(100L); + indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); + } + return indexed.orElseThrow( + () -> new AssertionError("Expected search document for skill " + skillId)); + } +} From de033da537c599d9aa65a5622a016d9718b91659 Mon Sep 17 00:00:00 2001 From: shychee Date: Wed, 22 Jul 2026 18:37:28 +0800 Subject: [PATCH 2/2] fix(search): make index writes REQUIRES_NEW to survive async caller-runs fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @Async rebuildSkill fix relied on a fresh thread giving a clean transaction boundary. But skillhubEventExecutor uses CallerRunsPolicy: under saturation the rejected task runs on the caller (request) thread, back inside the afterCommit synchronization phase — the original failure context where the @Transactional index write is silently dropped. Mark SearchIndexService.index as REQUIRES_NEW so it always suspends any lingering post-commit synchronization and commits in its own transaction, independent of whether the async dispatch actually happened. Add regression tests: detach removes the label keyword, and a synchronous rebuild inside the afterCommit phase still persists the document (fails without REQUIRES_NEW). Signed-off-by: shychee --- .../LabelSearchSyncIntegrationTest.java | 132 ++++++++++++++++++ .../PostgresFullTextIndexService.java | 3 +- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java index c7b9dd3a..257d33d2 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncIntegrationTest.java @@ -17,6 +17,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentEntity; import com.iflytek.skillhub.infra.jpa.SkillSearchDocumentJpaRepository; import com.iflytek.skillhub.search.SearchEmbeddingService; +import com.iflytek.skillhub.search.SearchRebuildService; import java.time.Duration; import java.time.Instant; import java.util.List; @@ -30,6 +31,9 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; @@ -69,6 +73,12 @@ class LabelSearchSyncIntegrationTest { @Autowired private SkillSearchDocumentJpaRepository skillSearchDocumentJpaRepository; + @Autowired + private SearchRebuildService searchRebuildService; + + @Autowired + private TransactionTemplate transactionTemplate; + @MockBean private SearchEmbeddingService searchEmbeddingService; @@ -124,6 +134,65 @@ class LabelSearchSyncIntegrationTest { .contains(labelDisplayName); } + @Test + void detachingLabel_removesKeywordFromSearchIndex() throws Exception { + Fixture f = createFixture(); + + skillLabelAppService.attachLabel( + f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext()); + SkillSearchDocumentEntity afterAttach = awaitIndexedDocument(f.skillId); + assertThat(afterAttach.getKeywords()) + .as("precondition: label keyword indexed after attach") + .contains(f.labelDisplayName); + + // Act: detach the same label. + skillLabelAppService.detachLabel( + f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext()); + + // Assert: the rebuilt document must no longer contain the label keyword. + awaitKeywordAbsent(f.skillId, f.labelDisplayName); + } + + /** + * Guards against the {@code CallerRunsPolicy} regression: when the executor is + * saturated, {@code rebuildSkill} runs synchronously on the request thread inside + * the {@code afterCommit} phase — the exact context where the index write used to be + * dropped. This exercises that path directly (no async hop) and asserts the document + * is still persisted, proving the fix relies on {@code REQUIRES_NEW}, not on the + * executor having spare capacity. + */ + @Test + void syncRebuildInAfterCommitPhase_persistsIndex() throws Exception { + Fixture f = createFixture(); + + // Establish the skill-label association and a baseline index via the normal path. + skillLabelAppService.attachLabel( + f.namespaceSlug, f.skillSlug, f.labelSlug, f.ownerId, f.ownerRoles, auditContext()); + awaitIndexedDocument(f.skillId); + + // Clear the index so we can observe the synchronous rebuild in isolation. + transactionTemplate.executeWithoutResult( + status -> skillSearchDocumentJpaRepository.deleteBySkillId(f.skillId)); + assertThat(skillSearchDocumentJpaRepository.findBySkillId(f.skillId)).isEmpty(); + + // Rebuild synchronously on the caller thread, inside a post-commit synchronization + // (mirrors the CallerRuns fallback from afterCommit(() -> rebuildSkill(...))). + transactionTemplate.executeWithoutResult(status -> + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + searchRebuildService.rebuildBySkill(f.skillId); + } + })); + + SkillSearchDocumentEntity indexed = skillSearchDocumentJpaRepository.findBySkillId(f.skillId) + .orElseThrow(() -> new AssertionError( + "synchronous rebuild in afterCommit phase must persist the index document")); + assertThat(indexed.getKeywords()) + .as("label keyword must be indexed even on the synchronous caller-runs path") + .contains(f.labelDisplayName); + } + private SkillSearchDocumentEntity awaitIndexedDocument(Long skillId) throws InterruptedException { Instant deadline = Instant.now().plus(Duration.ofSeconds(15)); Optional indexed = skillSearchDocumentJpaRepository.findBySkillId(skillId); @@ -134,4 +203,67 @@ class LabelSearchSyncIntegrationTest { return indexed.orElseThrow( () -> new AssertionError("Expected search document for skill " + skillId)); } + + private void awaitKeywordAbsent(Long skillId, String keyword) throws InterruptedException { + Instant deadline = Instant.now().plus(Duration.ofSeconds(15)); + while (Instant.now().isBefore(deadline)) { + Optional indexed = + skillSearchDocumentJpaRepository.findBySkillId(skillId); + if (indexed.isPresent() && !indexed.get().getKeywords().contains(keyword)) { + return; + } + Thread.sleep(100L); + } + String keywords = skillSearchDocumentJpaRepository.findBySkillId(skillId) + .map(SkillSearchDocumentEntity::getKeywords) + .orElse(""); + throw new AssertionError( + "Expected keyword '" + keyword + "' to be removed from index for skill " + + skillId + " but keywords were: " + keywords); + } + + private AuditRequestContext auditContext() { + return new AuditRequestContext("127.0.0.1", "junit"); + } + + private Fixture createFixture() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String ownerId = "owner-" + suffix; + // ASCII display name so the tokenizer keeps it as a single searchable token. + String labelDisplayName = "MachineLearning" + suffix; + String labelSlug = "ml-" + suffix; + + Namespace namespace = new Namespace("ns-" + suffix, "NS " + suffix, ownerId); + namespace.setType(NamespaceType.GLOBAL); + namespace = namespaceRepository.save(namespace); + + Skill skill = new Skill(namespace.getId(), "skill-" + suffix, ownerId, SkillVisibility.PUBLIC); + skill.setDisplayName("Skill " + suffix); + skill.setSummary("A skill used to reproduce the label search sync bug."); + skill.setCreatedBy(ownerId); + skill.setUpdatedBy(ownerId); + skill = skillRepository.save(skill); + skillRepository.flush(); + + LabelDefinition label = labelDefinitionRepository.save( + new LabelDefinition(labelSlug, LabelType.RECOMMENDED, true, 0, ownerId)); + labelTranslationRepository.saveAll(List.of( + new LabelTranslation(label.getId(), "en", labelDisplayName))); + labelTranslationRepository.flush(); + + return new Fixture( + namespace.getSlug(), skill.getSlug(), skill.getId(), + labelSlug, labelDisplayName, ownerId, + Map.of(namespace.getId(), NamespaceRole.OWNER)); + } + + private record Fixture( + String namespaceSlug, + String skillSlug, + Long skillId, + String labelSlug, + String labelDisplayName, + String ownerId, + Map ownerRoles) { + } } diff --git a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java index bac3cce1..304b5ac0 100644 --- a/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java +++ b/server/skillhub-search/src/main/java/com/iflytek/skillhub/search/postgres/PostgresFullTextIndexService.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.search.SearchEmbeddingService; import com.iflytek.skillhub.search.SearchIndexService; import com.iflytek.skillhub.search.SkillSearchDocument; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -32,7 +33,7 @@ public class PostgresFullTextIndexService implements SearchIndexService { } @Override - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public void index(SkillSearchDocument document) { SkillSearchDocument normalizedDocument = normalize(document); Optional existing = repository.findBySkillId(document.skillId());