From 0b31c1636a27c7482ceb188b9f3f8de57e0538b7 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:20:27 +0800 Subject: [PATCH 1/3] fix(search): improve async rebuild observability Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../iflytek/skillhub/config/AsyncConfig.java | 31 +++++++++- .../skillhub/metrics/SkillHubMetrics.java | 4 ++ .../service/LabelSearchSyncService.java | 8 ++- .../skillhub/config/AsyncConfigTest.java | 26 ++++++++ .../metrics/PrometheusEndpointTest.java | 4 ++ .../service/LabelSearchSyncServiceTest.java | 62 ++++++++++++++++++- 6 files changed, 130 insertions(+), 5 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java index 7feb8955..2b0efdba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AsyncConfig.java @@ -1,14 +1,16 @@ package com.iflytek.skillhub.config; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; +import org.slf4j.MDC; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskDecorator; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; -import java.util.concurrent.Executor; -import java.util.concurrent.ThreadPoolExecutor; - /** * Enables asynchronous event handling and other background execution features used by the * application module. @@ -26,7 +28,30 @@ public class AsyncConfig { executor.setQueueCapacity(100); executor.setThreadNamePrefix("skillhub-event-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setTaskDecorator(mdcTaskDecorator()); executor.initialize(); return executor; } + + private TaskDecorator mdcTaskDecorator() { + return task -> { + Map callerContext = MDC.getCopyOfContextMap(); + return () -> { + Map executorContext = MDC.getCopyOfContextMap(); + try { + restoreMdc(callerContext); + task.run(); + } finally { + restoreMdc(executorContext); + } + }; + }; + } + + private void restoreMdc(Map context) { + MDC.clear(); + if (context != null) { + MDC.setContextMap(context); + } + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java index abbd05dd..17627c2a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/metrics/SkillHubMetrics.java @@ -60,4 +60,8 @@ public class SkillHubMetrics { "operation", operation ).increment(); } + + public void incrementSearchRebuildFailure() { + meterRegistry.counter("skillhub.search.rebuild.failure").increment(); + } } 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 0dc9ce41..0a3ab591 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 @@ -1,5 +1,6 @@ package com.iflytek.skillhub.service; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.search.SearchRebuildService; import java.util.List; import org.slf4j.Logger; @@ -15,9 +16,12 @@ public class LabelSearchSyncService { private static final Logger log = LoggerFactory.getLogger(LabelSearchSyncService.class); private final SearchRebuildService searchRebuildService; + private final SkillHubMetrics metrics; - public LabelSearchSyncService(SearchRebuildService searchRebuildService) { + public LabelSearchSyncService(SearchRebuildService searchRebuildService, + SkillHubMetrics metrics) { this.searchRebuildService = searchRebuildService; + this.metrics = metrics; } @Async("skillhubEventExecutor") @@ -25,6 +29,7 @@ public class LabelSearchSyncService { try { searchRebuildService.rebuildBySkill(skillId); } catch (RuntimeException ex) { + metrics.incrementSearchRebuildFailure(); log.error("Failed to rebuild search document for skill {}", skillId, ex); } } @@ -41,6 +46,7 @@ public class LabelSearchSyncService { try { searchRebuildService.rebuildBySkill(skillId); } catch (RuntimeException ex) { + metrics.incrementSearchRebuildFailure(); log.error("Failed to rebuild search document for skill {} after label change", skillId, ex); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java index a5ac41d6..9c8f4305 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AsyncConfigTest.java @@ -2,9 +2,13 @@ package com.iflytek.skillhub.config; import static org.assertj.core.api.Assertions.assertThat; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; +import org.slf4j.MDC; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; class AsyncConfigTest { @@ -13,4 +17,26 @@ class AsyncConfigTest { assertThat(AsyncConfig.class).hasAnnotation(EnableAsync.class); assertThat(AsyncConfig.class).hasAnnotation(EnableScheduling.class); } + + @Test + void skillhubEventExecutor_propagatesAndClearsMdc() throws Exception { + ThreadPoolTaskExecutor executor = + (ThreadPoolTaskExecutor) new AsyncConfig().skillhubEventExecutor(); + try { + MDC.put("requestId", "req-597"); + CompletableFuture propagatedRequestId = new CompletableFuture<>(); + executor.execute(() -> propagatedRequestId.complete(MDC.get("requestId"))); + MDC.clear(); + + assertThat(propagatedRequestId.get(5, TimeUnit.SECONDS)).isEqualTo("req-597"); + + CompletableFuture nextRequestId = new CompletableFuture<>(); + executor.execute(() -> nextRequestId.complete(MDC.get("requestId"))); + + assertThat(nextRequestId.get(5, TimeUnit.SECONDS)).isNull(); + } finally { + MDC.clear(); + executor.shutdown(); + } + } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java index 38373f64..f52d66c7 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/metrics/PrometheusEndpointTest.java @@ -39,6 +39,7 @@ class PrometheusEndpointTest { skillHubMetrics.incrementUserRegister(); skillHubMetrics.recordLocalLogin(true); skillHubMetrics.incrementSkillPublish("global", "PENDING_REVIEW"); + skillHubMetrics.incrementSearchRebuildFailure(); assertThat(environment.getProperty("management.endpoints.web.exposure.include")) .doesNotContain("prometheus") @@ -54,5 +55,8 @@ class PrometheusEndpointTest { .tag("status", "PENDING_REVIEW") .counter() .count()).isEqualTo(1.0d); + assertThat(meterRegistry.get("skillhub.search.rebuild.failure") + .counter() + .count()).isEqualTo(1.0d); } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncServiceTest.java index c6859bed..ae0e5ac1 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/LabelSearchSyncServiceTest.java @@ -1,13 +1,19 @@ package com.iflytek.skillhub.service; +import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.search.SearchRebuildService; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; import java.util.ArrayList; import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; class LabelSearchSyncServiceTest { @@ -15,7 +21,8 @@ class LabelSearchSyncServiceTest { @Test void rebuildSkillsShouldSkipNullsAndDuplicatesWhileProcessingLargeLists() { SearchRebuildService rebuildService = mock(SearchRebuildService.class); - LabelSearchSyncService service = new LabelSearchSyncService(rebuildService); + SkillHubMetrics metrics = mock(SkillHubMetrics.class); + LabelSearchSyncService service = new LabelSearchSyncService(rebuildService, metrics); List skillIds = new ArrayList<>(); skillIds.add(null); for (long i = 1; i <= 120; i++) { @@ -30,5 +37,58 @@ class LabelSearchSyncServiceTest { verify(rebuildService).rebuildBySkill(i); } verifyNoMoreInteractions(rebuildService); + verifyNoInteractions(metrics); + } + + @Test + void rebuildSkillFailureShouldIncrementMetric() { + SearchRebuildService rebuildService = mock(SearchRebuildService.class); + doThrow(new IllegalStateException("search unavailable")) + .when(rebuildService) + .rebuildBySkill(42L); + + contextRunner(rebuildService).run(context -> { + LabelSearchSyncService service = context.getBean(LabelSearchSyncService.class); + SimpleMeterRegistry meterRegistry = context.getBean(SimpleMeterRegistry.class); + + service.rebuildSkill(42L); + + assertThat(meterRegistry.get("skillhub.search.rebuild.failure").counter().count()) + .isEqualTo(1.0d); + }); + } + + @Test + void rebuildSkillsShouldCountEachFailureAndContinue() { + SearchRebuildService rebuildService = mock(SearchRebuildService.class); + doThrow(new IllegalStateException("search unavailable")) + .when(rebuildService) + .rebuildBySkill(2L); + doThrow(new IllegalStateException("search unavailable")) + .when(rebuildService) + .rebuildBySkill(3L); + + contextRunner(rebuildService).run(context -> { + LabelSearchSyncService service = context.getBean(LabelSearchSyncService.class); + SimpleMeterRegistry meterRegistry = context.getBean(SimpleMeterRegistry.class); + + service.rebuildSkills(List.of(1L, 2L, 3L, 4L)); + + assertThat(meterRegistry.get("skillhub.search.rebuild.failure").counter().count()) + .isEqualTo(2.0d); + verify(rebuildService).rebuildBySkill(1L); + verify(rebuildService).rebuildBySkill(2L); + verify(rebuildService).rebuildBySkill(3L); + verify(rebuildService).rebuildBySkill(4L); + verifyNoMoreInteractions(rebuildService); + }); + } + + private ApplicationContextRunner contextRunner(SearchRebuildService rebuildService) { + return new ApplicationContextRunner() + .withBean(SearchRebuildService.class, () -> rebuildService) + .withBean(SimpleMeterRegistry.class) + .withBean(SkillHubMetrics.class) + .withBean(LabelSearchSyncService.class); } } From 636739492f38d53b5059ee894dbc7371dcd99868 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:12:44 +0800 Subject: [PATCH 2/3] fix(ci): make identity test Java detection pipe-safe Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- scripts/tests/identity-binding-v2-postgres-test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/tests/identity-binding-v2-postgres-test.sh b/scripts/tests/identity-binding-v2-postgres-test.sh index bf914dd8..5b55d517 100755 --- a/scripts/tests/identity-binding-v2-postgres-test.sh +++ b/scripts/tests/identity-binding-v2-postgres-test.sh @@ -48,7 +48,7 @@ run_test() { flyway_target="${2:-}" java_version="" if command -v java >/dev/null 2>&1; then - java_version="$(java -version 2>&1 | head -n 1)" + java_version="$(java -version 2>&1)" fi if [[ "${java_version}" == *'"21.'* ]]; then host_port="$(docker port "${POSTGRES_CONTAINER}" 5432/tcp \ From 32ee1d8108e1f835937b174c896fabfcb6c771c7 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:19:13 +0800 Subject: [PATCH 3/3] fix(ci): expose binding v2 test startup failures Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .../identity-binding-v2-postgres-test.sh | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/tests/identity-binding-v2-postgres-test.sh b/scripts/tests/identity-binding-v2-postgres-test.sh index 5b55d517..4977880b 100755 --- a/scripts/tests/identity-binding-v2-postgres-test.sh +++ b/scripts/tests/identity-binding-v2-postgres-test.sh @@ -10,15 +10,29 @@ POSTGRES_PASSWORD="identity-v2-test-password" POSTGRES_DB="identity_v2" MAVEN_CACHE_DIR="${MAVEN_CACHE_DIR:-${HOME}/.m2}" +log() { + printf '[identity-binding-v2] %s\n' "$*" +} + cleanup() { + exit_code="$?" + if [[ "${exit_code}" -ne 0 ]]; then + log "failed with exit code ${exit_code}" + docker ps -a \ + --filter "label=skillhub.test.run=${RUN_ID}" \ + --format 'resource={{.Names}} status={{.Status}}' || true + docker logs "${POSTGRES_CONTAINER}" 2>&1 || true + fi docker rm -f "${POSTGRES_CONTAINER}" >/dev/null 2>&1 || true docker network rm "${NETWORK}" >/dev/null 2>&1 || true } trap cleanup EXIT +log "creating isolated Docker network ${NETWORK}" docker network create \ --label "skillhub.test.run=${RUN_ID}" \ "${NETWORK}" >/dev/null +log "starting isolated PostgreSQL container ${POSTGRES_CONTAINER}" docker run -d \ --name "${POSTGRES_CONTAINER}" \ --label "skillhub.test.run=${RUN_ID}" \ @@ -31,6 +45,7 @@ docker run -d \ -p 127.0.0.1::5432 \ postgres:16-alpine >/dev/null +log "waiting for PostgreSQL readiness" for _ in $(seq 1 60); do if docker exec "${POSTGRES_CONTAINER}" \ pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \ @@ -42,6 +57,7 @@ done docker exec "${POSTGRES_CONTAINER}" \ pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \ >/dev/null +log "PostgreSQL is ready" run_test() { test_class="$1" @@ -53,6 +69,11 @@ run_test() { if [[ "${java_version}" == *'"21.'* ]]; then host_port="$(docker port "${POSTGRES_CONTAINER}" 5432/tcp \ | sed -n 's/.*://p')" + if [[ -z "${host_port}" ]]; then + log "Docker did not publish a PostgreSQL host port" + return 1 + fi + log "running ${test_class} with host Java 21" ( cd "${REPO_ROOT}/server" IDENTITY_BINDING_V2_POSTGRES_URL="jdbc:postgresql://127.0.0.1:${host_port}/${POSTGRES_DB}" \ @@ -70,6 +91,7 @@ run_test() { return fi + log "running ${test_class} with containerized Java 21" mkdir -p "${MAVEN_CACHE_DIR}" docker run --rm \ --name "${RUN_ID}-java" \