diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiFactPageUpdatedEvent.java b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiFactPageUpdatedEvent.java new file mode 100644 index 00000000..cadb7438 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/event/WikiFactPageUpdatedEvent.java @@ -0,0 +1,11 @@ +package vip.mate.wiki.event; + +/** + * Published after a fact-layer page is updated during ingest (already + * committed). Consumed asynchronously to mark the experience pages that depend + * on it as stale, without blocking ingest. + * + * @author MateClaw Team + */ +public record WikiFactPageUpdatedEvent(Long kbId, Long factPageId, String reason) { +} diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index 978ca110..ae26c78e 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -1429,7 +1429,7 @@ public class WikiProcessingService { String actualSlug = existingByCanonical.getSlug(); pageService.updatePageByAi(kbId, actualSlug, content, pageSummary, rawId); pageService.mergeSourceLineage(existingByCanonical.getId(), rawId, raw.getTitle()); - afterPagePersisted(existingByCanonical.getId(), kbId, pageType, metadataNode, dependsOnNode); + afterPagePersisted(existingByCanonical.getId(), kbId, pageType, metadataNode, dependsOnNode, true); log.info("[Wiki] Phase B create slug='{}' canonical-matches existing '{}', updated", slug, actualSlug); return false; @@ -1446,7 +1446,7 @@ public class WikiProcessingService { if (winner != null) { pageService.updatePageByAi(kbId, winnerSlug, content, pageSummary, rawId); pageService.mergeSourceLineage(winner.getId(), rawId, raw.getTitle()); - afterPagePersisted(winner.getId(), kbId, pageType, metadataNode, dependsOnNode); + afterPagePersisted(winner.getId(), kbId, pageType, metadataNode, dependsOnNode, true); log.info("[Wiki] Phase B create slug='{}' lost slug-claim race to '{}', updated", slug, winnerSlug); return false; @@ -1462,7 +1462,7 @@ public class WikiProcessingService { if (existing != null) { pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId); pageService.mergeSourceLineage(existing.getId(), rawId, raw.getTitle()); - afterPagePersisted(existing.getId(), kbId, pageType, metadataNode, dependsOnNode); + afterPagePersisted(existing.getId(), kbId, pageType, metadataNode, dependsOnNode, true); log.info("[Wiki] Phase B create page slug='{}' done (updated existing)", slug); return false; } @@ -1471,7 +1471,7 @@ public class WikiProcessingService { try { WikiPageEntity created = pageService.createPage(kbId, slug, title, content, pageSummary, sourceRawIds, pageType); pageService.mergeSourceLineage(created.getId(), rawId, raw.getTitle()); - afterPagePersisted(created.getId(), kbId, pageType, metadataNode, dependsOnNode); + afterPagePersisted(created.getId(), kbId, pageType, metadataNode, dependsOnNode, false); log.info("[Wiki] Phase B create page slug='{}' done (created)", slug); citationService.buildCitationsAsync(created.getId(), kbId); return true; @@ -1480,7 +1480,7 @@ public class WikiProcessingService { pageService.updatePageByAi(kbId, slug, content, pageSummary, rawId); WikiPageEntity raced = pageService.getBySlug(kbId, slug); if (raced != null) { - afterPagePersisted(raced.getId(), kbId, pageType, metadataNode, dependsOnNode); + afterPagePersisted(raced.getId(), kbId, pageType, metadataNode, dependsOnNode, true); } log.info("[Wiki] Phase B create page slug='{}' lost INSERT race -> updated existing", slug); return false; @@ -1500,7 +1500,7 @@ public class WikiProcessingService { * the same metadata and trigger handling as a clean create. */ private void afterPagePersisted(Long pageId, Long kbId, String pageType, - JsonNode metadataNode, JsonNode dependsOnNode) { + JsonNode metadataNode, JsonNode dependsOnNode, boolean isUpdate) { applyValidatedMetadata(pageId, kbId, pageType, metadataNode); deriveKnowledgeLayer(pageId, kbId, pageType); applyDependencies(pageId, kbId, pageType, dependsOnNode); @@ -1510,6 +1510,13 @@ public class WikiProcessingService { if (eventPublisher != null && pageType != null && !pageType.isBlank()) { eventPublisher.publishEvent(new vip.mate.wiki.event.WikiPageCreatedEvent(kbId, pageType)); } + // When an existing fact page is updated, propagate staleness to the + // experience pages depending on it (async, off the ingest thread). + if (isUpdate && eventPublisher != null && dependencyService != null + && pageTypeProfileService != null && !pageTypeProfileService.isExperience(kbId, pageType)) { + eventPublisher.publishEvent(new vip.mate.wiki.event.WikiFactPageUpdatedEvent( + kbId, pageId, "fact page updated during ingest")); + } } /** Stamp the page's knowledge layer (fact/experience) derived from its pageType profile. */ diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiStalePropagationListener.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiStalePropagationListener.java new file mode 100644 index 00000000..81ed2a92 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiStalePropagationListener.java @@ -0,0 +1,37 @@ +package vip.mate.wiki.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import vip.mate.wiki.event.WikiFactPageUpdatedEvent; + +/** + * Propagates staleness asynchronously when a fact-layer page is updated: every + * experience page depending on it is marked stale. Runs off the ingest thread + * so a fan-out over many dependents never blocks ingest; the fact page is + * already committed when the event fires. Marking is idempotent, so repeated + * events for the same page are harmless. + * + * @author MateClaw Team + */ +@Slf4j +@Component +public class WikiStalePropagationListener { + + private final WikiDependencyService dependencyService; + + public WikiStalePropagationListener(WikiDependencyService dependencyService) { + this.dependencyService = dependencyService; + } + + @Async + @EventListener + public void onFactPageUpdated(WikiFactPageUpdatedEvent event) { + try { + dependencyService.markDependentsStale(event.kbId(), event.factPageId(), event.reason()); + } catch (Exception e) { + log.warn("[WikiStale] propagation failed for fact page {}: {}", event.factPageId(), e.getMessage()); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiStalePropagationListenerTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiStalePropagationListenerTest.java new file mode 100644 index 00000000..23ee3664 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiStalePropagationListenerTest.java @@ -0,0 +1,38 @@ +package vip.mate.wiki.service; + +import org.junit.jupiter.api.Test; +import vip.mate.wiki.event.WikiFactPageUpdatedEvent; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link WikiStalePropagationListener}: it forwards a fact-page + * update to the dependency engine and never lets a failure escape (so a broken + * propagation cannot disturb ingest). + */ +class WikiStalePropagationListenerTest { + + @Test + void forwardsToMarkDependentsStale() { + WikiDependencyService dep = mock(WikiDependencyService.class); + when(dep.markDependentsStale(7L, 100L, "r")).thenReturn(2); + + new WikiStalePropagationListener(dep) + .onFactPageUpdated(new WikiFactPageUpdatedEvent(7L, 100L, "r")); + + verify(dep).markDependentsStale(7L, 100L, "r"); + } + + @Test + void swallowsFailure() { + WikiDependencyService dep = mock(WikiDependencyService.class); + doThrow(new RuntimeException("boom")).when(dep).markDependentsStale(7L, 100L, "r"); + + // Must not throw. + new WikiStalePropagationListener(dep) + .onFactPageUpdated(new WikiFactPageUpdatedEvent(7L, 100L, "r")); + } +}