feat(wiki): propagate staleness when a fact page is updated during ingest

This commit is contained in:
matevip 2026-05-31 07:59:27 +08:00
parent 55cf2a8458
commit ea0ace1e49
4 changed files with 99 additions and 6 deletions

View File

@ -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) {
}

View File

@ -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. */

View File

@ -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());
}
}
}

View File

@ -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"));
}
}