fix(wiki): recover raw materials stuck in processing on server restart

Root cause: recoverOnStartup() only reset mate_wiki_processing_job rows,
not mate_wiki_raw_material. claimForProcessing() only accepts pending,
so restart-orphaned processing rows were permanently stuck — frontend
showed "preparing..." forever.

Fix:
- Add WikiRawMaterialService.recoverStuckRawMaterialsOnStartup():
  resets processing→pending, clears progress fields, fires
  WikiProcessingEvent when autoProcessOnUpload is enabled
- WikiAutoConfiguration: call raw recovery after job recovery
- Execution order: job table first (queued), then raw table (pending)

Test: WikiRawMaterialRecoveryTest — 4 cases: reset + events, reset
without events (autoProcess=false), noop on empty.
This commit is contained in:
matevip 2026-04-19 19:37:50 +08:00
parent cbcb7229b6
commit a171f2ac0e
3 changed files with 149 additions and 1 deletions

View File

@ -7,6 +7,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import vip.mate.wiki.job.WikiProcessingJobService;
import vip.mate.wiki.service.WikiRawMaterialService;
/**
* Wiki module auto-configuration
@ -20,12 +21,19 @@ import vip.mate.wiki.job.WikiProcessingJobService;
public class WikiAutoConfiguration {
private final WikiProcessingJobService wikiProcessingJobService;
private final WikiRawMaterialService wikiRawMaterialService;
/**
* RFC-030: Recover stuck wiki processing jobs on startup.
* Recover stuck wiki state on startup:
* 1. Job table: routing/*_running queued (RFC-030)
* 2. Raw material table: processing pending (avoids forever-spinning progress bars)
*/
@EventListener(ApplicationReadyEvent.class)
public void recoverWikiJobs(ApplicationReadyEvent event) {
wikiProcessingJobService.recoverOnStartup();
int recovered = wikiRawMaterialService.recoverStuckRawMaterialsOnStartup();
if (recovered > 0) {
log.info("[Wiki] Recovered {} stuck raw materials on startup", recovered);
}
}
}

View File

@ -332,6 +332,36 @@ public class WikiRawMaterialService {
return entity.getOriginalContent();
}
/**
* Recover raw materials stuck in 'processing' status after a server restart.
* Resets them to 'pending', clears stale progress fields, and optionally
* fires processing events so they get picked up automatically.
*
* @return number of recovered rows
*/
@Transactional
public int recoverStuckRawMaterialsOnStartup() {
List<WikiRawMaterialEntity> stuck = rawMapper.selectList(
new LambdaQueryWrapper<WikiRawMaterialEntity>()
.eq(WikiRawMaterialEntity::getProcessingStatus, "processing"));
if (stuck.isEmpty()) return 0;
for (WikiRawMaterialEntity raw : stuck) {
raw.setProcessingStatus("pending");
raw.setProgressPhase(null);
raw.setProgressTotal(0);
raw.setProgressDone(0);
raw.setErrorMessage(null);
rawMapper.updateById(raw);
if (properties.isAutoProcessOnUpload()) {
eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), raw.getKbId()));
}
log.info("[Wiki] Recovered stuck processing raw material: id={}, kbId={}", raw.getId(), raw.getKbId());
}
return stuck.size();
}
/**
* Handle a duplicate upload: decide what to do based on the existing row's status.
* - completed return as-is (no reprocessing needed)

View File

@ -0,0 +1,110 @@
package vip.mate.wiki.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.tool.builtin.DocumentExtractTool;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.event.WikiProcessingEvent;
import vip.mate.wiki.model.WikiRawMaterialEntity;
import vip.mate.wiki.repository.WikiRawMaterialMapper;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Verifies that raw materials stuck in 'processing' status after a server
* restart are recovered to 'pending' with progress fields cleared.
*/
class WikiRawMaterialRecoveryTest {
private WikiRawMaterialMapper rawMapper;
private ApplicationEventPublisher eventPublisher;
private WikiRawMaterialService service;
private WikiProperties props;
@BeforeEach
void setUp() {
rawMapper = mock(WikiRawMaterialMapper.class);
WikiKnowledgeBaseService kbService = mock(WikiKnowledgeBaseService.class);
eventPublisher = mock(ApplicationEventPublisher.class);
WikiChunkService chunkService = mock(WikiChunkService.class);
DocumentExtractTool docTool = mock(DocumentExtractTool.class);
props = new WikiProperties();
props.setAutoProcessOnUpload(true);
service = new WikiRawMaterialService(rawMapper, kbService, props, eventPublisher, docTool, chunkService);
}
private WikiRawMaterialEntity stuckRow(Long id, Long kbId) {
WikiRawMaterialEntity e = new WikiRawMaterialEntity();
e.setId(id);
e.setKbId(kbId);
e.setProcessingStatus("processing");
e.setProgressPhase("route");
e.setProgressTotal(5);
e.setProgressDone(2);
return e;
}
@Test
@DisplayName("Stuck 'processing' rows are reset to 'pending' with cleared progress")
void recoverStuck_resetsToPending() {
WikiRawMaterialEntity row1 = stuckRow(10L, 1L);
WikiRawMaterialEntity row2 = stuckRow(20L, 2L);
when(rawMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row1, row2));
int count = service.recoverStuckRawMaterialsOnStartup();
assertEquals(2, count);
assertEquals("pending", row1.getProcessingStatus());
assertNull(row1.getProgressPhase());
assertEquals(0, row1.getProgressTotal());
assertEquals(0, row1.getProgressDone());
assertEquals("pending", row2.getProcessingStatus());
verify(rawMapper, times(2)).updateById(any(WikiRawMaterialEntity.class));
}
@Test
@DisplayName("Recovery fires WikiProcessingEvent when autoProcessOnUpload is true")
void recoverStuck_firesEvents() {
WikiRawMaterialEntity row = stuckRow(10L, 1L);
when(rawMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row));
service.recoverStuckRawMaterialsOnStartup();
ArgumentCaptor<WikiProcessingEvent> captor = ArgumentCaptor.forClass(WikiProcessingEvent.class);
verify(eventPublisher).publishEvent(captor.capture());
assertEquals(10L, captor.getValue().getRawMaterialId());
}
@Test
@DisplayName("Recovery does NOT fire events when autoProcessOnUpload is false")
void recoverStuck_noEventsWhenDisabled() {
props.setAutoProcessOnUpload(false);
WikiRawMaterialEntity row = stuckRow(10L, 1L);
when(rawMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of(row));
service.recoverStuckRawMaterialsOnStartup();
assertEquals("pending", row.getProcessingStatus());
verify(eventPublisher, never()).publishEvent(any());
}
@Test
@DisplayName("No stuck rows means zero recovery, no events")
void recoverStuck_emptyIsNoop() {
when(rawMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(List.of());
int count = service.recoverStuckRawMaterialsOnStartup();
assertEquals(0, count);
verify(rawMapper, never()).updateById(any(WikiRawMaterialEntity.class));
verify(eventPublisher, never()).publishEvent(any());
}
}