feat: 展示分块索引同步重试状态
- 返回当前版本任务的真实尝试次数与重试原因,区分模型服务和索引写入失败 - 分离提示与按钮布局,保持操作位置稳定,补充分块状态回归测试
This commit is contained in:
@@ -5,6 +5,7 @@ import com.easyagents.core.model.embedding.EmbeddingOptions;
|
||||
import com.easyagents.core.store.DocumentStore;
|
||||
import com.easyagents.core.store.StoreOptions;
|
||||
import com.easyagents.core.store.StoreResult;
|
||||
import com.easyagents.core.store.VectorData;
|
||||
import com.easyagents.search.engine.service.DocumentSearcher;
|
||||
import com.easyagents.search.engine.service.KeywordSearchMetadataKeys;
|
||||
import com.easyagents.store.milvus.MilvusVectorStore;
|
||||
@@ -22,6 +23,7 @@ import tech.easyflow.ai.entity.DocumentChunk;
|
||||
import tech.easyflow.ai.entity.DocumentChunkSyncTask;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.dto.DocumentChunkSyncStatus;
|
||||
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
||||
import tech.easyflow.ai.mapper.DocumentChunkSyncTaskMapper;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
@@ -33,7 +35,12 @@ import java.math.BigInteger;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 持久化分块索引同步任务的投递、执行和恢复。
|
||||
@@ -116,6 +123,31 @@ public class DocumentChunkSyncTaskAppService {
|
||||
}
|
||||
}
|
||||
|
||||
public List<DocumentChunkSyncStatus> listSyncStatuses(List<DocumentChunk> chunks) {
|
||||
if (chunks.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Map<BigInteger, DocumentChunkSyncTask> tasks = taskMapper.selectCurrentForChunks(
|
||||
chunks.stream().map(DocumentChunk::getId).toList()
|
||||
).stream().collect(Collectors.toMap(DocumentChunkSyncTask::getChunkId, Function.identity()));
|
||||
return chunks.stream().map(chunk -> {
|
||||
DocumentChunkSyncTask task = tasks.get(chunk.getId());
|
||||
boolean current = task != null
|
||||
&& Objects.equals(chunk.getIndexSyncVersion(), task.getSyncVersion());
|
||||
// 正文状态可能在两次查询间推进,只有同版本仍在重试的任务提供失败原因。
|
||||
boolean retrying = current && DocumentChunkSyncState.PENDING.equals(chunk.getIndexSyncStatus())
|
||||
&& (DocumentChunkSyncState.PENDING.equals(task.getStatus())
|
||||
|| DocumentChunkSyncState.TASK_RUNNING.equals(task.getStatus()));
|
||||
return new DocumentChunkSyncStatus(
|
||||
chunk.getId(), chunk.getIndexSyncStatus(), chunk.getIndexSyncVersion(),
|
||||
retrying ? task.getErrorCode() : chunk.getIndexSyncErrorCode(),
|
||||
retrying ? task.getErrorMessage() : chunk.getIndexSyncErrorMessage(),
|
||||
current ? task.getAttemptCount() : null,
|
||||
MAX_ATTEMPTS
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
public void dispatchPendingTasks() {
|
||||
Date now = new Date();
|
||||
taskMapper.recoverExpired(now);
|
||||
@@ -228,6 +260,7 @@ public class DocumentChunkSyncTaskAppService {
|
||||
StoreContext context = prepareUpsertContext(collection, task.getVectorCollection());
|
||||
try {
|
||||
com.easyagents.core.document.Document document = toSearchDocument(chunk, task.getDocumentCollectionId());
|
||||
embedDocument(context, document);
|
||||
StoreResult vectorResult = context.documentStore.update(
|
||||
Collections.singletonList(document),
|
||||
context.storeOptions
|
||||
@@ -248,6 +281,21 @@ public class DocumentChunkSyncTaskAppService {
|
||||
}
|
||||
}
|
||||
|
||||
private void embedDocument(StoreContext context, com.easyagents.core.document.Document document) {
|
||||
try {
|
||||
VectorData vectorData = context.documentStore.getEmbeddingModel().embed(
|
||||
document, context.storeOptions.getEmbeddingOptions()
|
||||
);
|
||||
if (vectorData == null || vectorData.getVector() == null || vectorData.getVector().length == 0) {
|
||||
throw new IllegalStateException("向量模型未返回有效向量");
|
||||
}
|
||||
// update 复用已生成的向量,不重复调用模型;分开捕获以区分模型与索引写入失败。
|
||||
document.setVector(vectorData.getVector());
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IndexSyncException("EMBEDDING_REQUEST_FAILED", "向量模型服务调用失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void synchronizeDelete(DocumentChunkSyncTask task) {
|
||||
MilvusVectorStoreConfig storeConfig = milvusConfig.copyForCollection(task.getVectorCollection());
|
||||
AiMilvusClientManager clientManager = milvusClientManagerProvider.getObject();
|
||||
|
||||
@@ -10,6 +10,8 @@ public record DocumentChunkSyncStatus(
|
||||
String indexSyncStatus,
|
||||
Long indexSyncVersion,
|
||||
String indexSyncErrorCode,
|
||||
String indexSyncErrorMessage
|
||||
String indexSyncErrorMessage,
|
||||
Integer indexSyncAttemptCount,
|
||||
int indexSyncMaxAttempts
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ public interface DocumentChunkSyncTaskMapper extends BaseMapper<DocumentChunkSyn
|
||||
+ "ORDER BY sync_version, id FOR UPDATE")
|
||||
List<BigInteger> lockChunkTasks(@Param("chunkId") BigInteger chunkId);
|
||||
|
||||
@Select("<script>SELECT " + SELECT_COLUMNS + " FROM tb_document_chunk_sync_task "
|
||||
+ "WHERE operation='UPSERT' AND (chunk_id, sync_version) IN "
|
||||
+ "(SELECT id, index_sync_version FROM tb_document_chunk WHERE id IN "
|
||||
+ "<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>)"
|
||||
+ "</script>")
|
||||
List<DocumentChunkSyncTask> selectCurrentForChunks(@Param("ids") List<BigInteger> ids);
|
||||
|
||||
@Select("SELECT " + SELECT_COLUMNS + " FROM tb_document_chunk_sync_task "
|
||||
+ "WHERE status='PENDING' AND next_retry_at <= #{now} "
|
||||
+ "AND (last_dispatched_at IS NULL OR last_dispatched_at <= #{redispatchBefore}) "
|
||||
@@ -67,7 +74,7 @@ public interface DocumentChunkSyncTaskMapper extends BaseMapper<DocumentChunkSyn
|
||||
|
||||
@Update("UPDATE tb_document_chunk_sync_task SET status='RUNNING', "
|
||||
+ "attempt_count=attempt_count + 1, execution_token=#{token}, "
|
||||
+ "lease_until=#{leaseUntil}, error_code=NULL, error_message=NULL, modified=#{now} "
|
||||
+ "lease_until=#{leaseUntil}, modified=#{now} "
|
||||
+ "WHERE id=#{id} AND status='PENDING' AND next_retry_at <= #{now}")
|
||||
int claim(@Param("id") BigInteger id,
|
||||
@Param("token") String token,
|
||||
|
||||
@@ -263,13 +263,7 @@ public class DocumentChunkServiceImpl
|
||||
throw new BusinessException("分块不存在");
|
||||
}
|
||||
}
|
||||
return chunks.stream().map(chunk -> new DocumentChunkSyncStatus(
|
||||
chunk.getId(),
|
||||
chunk.getIndexSyncStatus(),
|
||||
chunk.getIndexSyncVersion(),
|
||||
chunk.getIndexSyncErrorCode(),
|
||||
chunk.getIndexSyncErrorMessage()
|
||||
)).toList();
|
||||
return syncTaskAppService.listSyncStatuses(chunks);
|
||||
}
|
||||
|
||||
private DocumentChunk requireChunk(BigInteger knowledgeId, BigInteger chunkId) {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package tech.easyflow.ai.documentchunk;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.Assert;
|
||||
import com.easyagents.core.document.Document;
|
||||
import com.easyagents.core.model.embedding.EmbeddingModel;
|
||||
import com.easyagents.core.model.exception.ModelException;
|
||||
import com.easyagents.core.store.DocumentStore;
|
||||
import com.easyagents.core.store.StoreResult;
|
||||
import com.easyagents.core.store.VectorData;
|
||||
import com.easyagents.search.engine.service.DocumentSearcher;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
@@ -10,6 +18,8 @@ import tech.easyflow.ai.config.AiMilvusConfig;
|
||||
import tech.easyflow.ai.config.SearcherFactory;
|
||||
import tech.easyflow.ai.entity.DocumentChunk;
|
||||
import tech.easyflow.ai.entity.DocumentChunkSyncTask;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.entity.Model;
|
||||
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
||||
import tech.easyflow.ai.mapper.DocumentChunkSyncTaskMapper;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
@@ -25,6 +35,144 @@ import java.util.function.Supplier;
|
||||
*/
|
||||
public class DocumentChunkSyncTaskAppServiceTest {
|
||||
|
||||
@Test
|
||||
public void pollingShouldExposeCurrentRetryReasonWhileWaitingAndRunning() {
|
||||
Fixture fixture = fixture(1);
|
||||
fixture.task.setErrorCode("EMBEDDING_REQUEST_FAILED");
|
||||
fixture.task.setErrorMessage("向量模型服务调用失败");
|
||||
Mockito.when(fixture.taskMapper.selectCurrentForChunks(List.of(fixture.chunkId)))
|
||||
.thenReturn(List.of(fixture.task));
|
||||
|
||||
var pending = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||
Assert.assertEquals("PENDING", pending.indexSyncStatus());
|
||||
Assert.assertEquals("EMBEDDING_REQUEST_FAILED", pending.indexSyncErrorCode());
|
||||
Assert.assertEquals(Integer.valueOf(1), pending.indexSyncAttemptCount());
|
||||
Assert.assertEquals(5, pending.indexSyncMaxAttempts());
|
||||
|
||||
fixture.task.setStatus(DocumentChunkSyncState.TASK_RUNNING);
|
||||
fixture.task.setAttemptCount(2);
|
||||
var running = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||
Assert.assertEquals("EMBEDDING_REQUEST_FAILED", running.indexSyncErrorCode());
|
||||
Assert.assertEquals(Integer.valueOf(2), running.indexSyncAttemptCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pollingShouldNotExposeAnotherVersionsOrFinishedTasksFailure() {
|
||||
Fixture fixture = fixture(2);
|
||||
fixture.task.setErrorCode("EMBEDDING_REQUEST_FAILED");
|
||||
Mockito.when(fixture.taskMapper.selectCurrentForChunks(List.of(fixture.chunkId)))
|
||||
.thenReturn(List.of(fixture.task));
|
||||
fixture.task.setSyncVersion(2L);
|
||||
var changed = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||
Assert.assertNull(changed.indexSyncErrorCode());
|
||||
Assert.assertNull(changed.indexSyncAttemptCount());
|
||||
|
||||
fixture.task.setSyncVersion(1L);
|
||||
fixture.task.setStatus(DocumentChunkSyncState.TASK_SUCCEEDED);
|
||||
var finished = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||
Assert.assertNull(finished.indexSyncErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pollingShouldHandleChunksWithoutTasksAndSkipEmptyBatch() {
|
||||
Fixture fixture = fixture(1);
|
||||
Assert.assertTrue(fixture.service.listSyncStatuses(List.of()).isEmpty());
|
||||
Mockito.verify(fixture.taskMapper, Mockito.never()).selectCurrentForChunks(Mockito.anyList());
|
||||
Mockito.when(fixture.taskMapper.selectCurrentForChunks(List.of(fixture.chunkId)))
|
||||
.thenReturn(List.of());
|
||||
var state = fixture.service.listSyncStatuses(List.of(fixture.chunk)).get(0);
|
||||
Assert.assertEquals("PENDING", state.indexSyncStatus());
|
||||
Assert.assertNull(state.indexSyncAttemptCount());
|
||||
Assert.assertNull(state.indexSyncErrorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void embeddingFailureShouldBeReportedWithoutCallingIndexes() {
|
||||
Fixture fixture = fixture(1);
|
||||
IndexFixture indexes = prepareIndexes(fixture);
|
||||
Mockito.when(indexes.embeddingModel.embed(Mockito.any(Document.class), Mockito.any()))
|
||||
.thenThrow(new ModelException("response is null or empty."));
|
||||
|
||||
fixture.service.handleTask(fixture.taskId);
|
||||
|
||||
Mockito.verify(fixture.taskMapper).failOrRetryOwned(
|
||||
Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("PENDING"), Mockito.any(),
|
||||
Mockito.eq("EMBEDDING_REQUEST_FAILED"), Mockito.eq("向量模型服务调用失败"), Mockito.any()
|
||||
);
|
||||
Mockito.verify(indexes.store, Mockito.never()).doUpdate(Mockito.anyList(), Mockito.any());
|
||||
Mockito.verifyNoInteractions(indexes.searcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vectorFailureShouldHaveItsOwnReasonAndEmbedOnlyOnce() {
|
||||
Fixture fixture = fixture(1);
|
||||
IndexFixture indexes = prepareIndexes(fixture);
|
||||
Mockito.when(indexes.store.doUpdate(Mockito.anyList(), Mockito.any()))
|
||||
.thenReturn(StoreResult.fail("vector write failed"));
|
||||
|
||||
fixture.service.handleTask(fixture.taskId);
|
||||
|
||||
Mockito.verify(fixture.taskMapper).failOrRetryOwned(
|
||||
Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("PENDING"), Mockito.any(),
|
||||
Mockito.eq("VECTOR_UPSERT_FAILED"), Mockito.anyString(), Mockito.any()
|
||||
);
|
||||
Mockito.verify(indexes.embeddingModel).embed(Mockito.any(Document.class), Mockito.any());
|
||||
Mockito.verifyNoInteractions(indexes.searcher);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keywordFailureShouldHaveItsOwnReason() {
|
||||
Fixture fixture = fixture(1);
|
||||
IndexFixture indexes = prepareIndexes(fixture);
|
||||
Mockito.when(indexes.searcher.addDocuments(Mockito.anyList())).thenReturn(false);
|
||||
|
||||
fixture.service.handleTask(fixture.taskId);
|
||||
|
||||
Mockito.verify(fixture.taskMapper).failOrRetryOwned(
|
||||
Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("PENDING"), Mockito.any(),
|
||||
Mockito.eq("KEYWORD_UPSERT_FAILED"), Mockito.anyString(), Mockito.any()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void successfulIndexUpdateShouldClearFailureAndMarkChunkSynced() {
|
||||
Fixture fixture = fixture(2);
|
||||
fixture.task.setErrorCode("EMBEDDING_REQUEST_FAILED");
|
||||
IndexFixture indexes = prepareIndexes(fixture);
|
||||
Mockito.when(indexes.searcher.addDocuments(Mockito.anyList())).thenReturn(true);
|
||||
|
||||
fixture.service.handleTask(fixture.taskId);
|
||||
|
||||
Mockito.verify(fixture.taskMapper).finishOwned(
|
||||
Mockito.eq(fixture.taskId), Mockito.anyString(), Mockito.eq("SUCCEEDED"),
|
||||
Mockito.isNull(), Mockito.isNull(), Mockito.any()
|
||||
);
|
||||
Mockito.verify(fixture.chunkMapper).updateSyncState(fixture.chunkId, 1L, "SYNCED", null, null);
|
||||
Mockito.verify(indexes.embeddingModel).embed(Mockito.any(Document.class), Mockito.any());
|
||||
}
|
||||
|
||||
private static IndexFixture prepareIndexes(Fixture fixture) {
|
||||
DocumentCollection collection = Mockito.mock(DocumentCollection.class);
|
||||
DocumentStore store = Mockito.mock(DocumentStore.class, Mockito.CALLS_REAL_METHODS);
|
||||
Model model = Mockito.mock(Model.class);
|
||||
EmbeddingModel embeddingModel = Mockito.mock(EmbeddingModel.class);
|
||||
DocumentSearcher searcher = Mockito.mock(DocumentSearcher.class);
|
||||
Mockito.when(fixture.collectionService.getById(fixture.task.getDocumentCollectionId()))
|
||||
.thenReturn(collection);
|
||||
Mockito.when(collection.toDocumentStore()).thenReturn(store);
|
||||
Mockito.when(fixture.modelService.getModelInstance(Mockito.any())).thenReturn(model);
|
||||
Mockito.when(model.toEmbeddingModel()).thenReturn(embeddingModel);
|
||||
Mockito.when(fixture.searcherFactory.getSearcher()).thenReturn(searcher);
|
||||
VectorData vector = new VectorData();
|
||||
vector.setVector(new float[] { 0.1f, 0.2f });
|
||||
Mockito.when(embeddingModel.embed(Mockito.any(Document.class), Mockito.any())).thenReturn(vector);
|
||||
Mockito.when(store.doUpdate(Mockito.anyList(), Mockito.any())).thenReturn(StoreResult.success());
|
||||
return new IndexFixture(store, embeddingModel, searcher);
|
||||
}
|
||||
|
||||
private record IndexFixture(DocumentStore store, EmbeddingModel embeddingModel, DocumentSearcher searcher) {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dispatchPendingShouldRecoverExpiredTasksAndSendClaimedRows() {
|
||||
Fixture fixture = fixture(1);
|
||||
@@ -215,7 +363,7 @@ public class DocumentChunkSyncTaskAppServiceTest {
|
||||
Mockito.mock(ObjectProvider.class)
|
||||
);
|
||||
return new Fixture(service, taskMapper, chunkMapper, collectionService,
|
||||
producer, task, chunk, taskId, chunkId);
|
||||
producer, task, chunk, taskId, chunkId, modelService, searcherFactory);
|
||||
}
|
||||
|
||||
private record Fixture(
|
||||
@@ -227,7 +375,9 @@ public class DocumentChunkSyncTaskAppServiceTest {
|
||||
DocumentChunkSyncTask task,
|
||||
DocumentChunk chunk,
|
||||
BigInteger taskId,
|
||||
BigInteger chunkId
|
||||
BigInteger chunkId,
|
||||
ModelService modelService,
|
||||
SearcherFactory searcherFactory
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,11 @@ public class DocumentChunkServiceImplTest {
|
||||
Mockito.when(fixture.chunkMapper.selectSyncStates(
|
||||
fixture.documentId, List.of(fixture.chunkId)
|
||||
)).thenReturn(List.of(state));
|
||||
Mockito.when(fixture.syncTaskAppService.listSyncStatuses(List.of(state)))
|
||||
.thenReturn(List.of(new tech.easyflow.ai.dto.DocumentChunkSyncStatus(
|
||||
state.getId(), state.getIndexSyncStatus(), state.getIndexSyncVersion(),
|
||||
null, null, 0, 5
|
||||
)));
|
||||
|
||||
List<tech.easyflow.ai.dto.DocumentChunkSyncStatus> result =
|
||||
fixture.service.listIndexSyncStatus(
|
||||
|
||||
@@ -282,6 +282,10 @@
|
||||
"discardChanges": "Discard changes",
|
||||
"chunkSourceFallback": "Switched to source editing",
|
||||
"chunkSyncPending": "Updating search index",
|
||||
"chunkSyncEmbeddingRetry": "Retrying embedding service ({attempt}/{maxAttempts})",
|
||||
"chunkSyncVectorRetry": "Retrying vector index ({attempt}/{maxAttempts})",
|
||||
"chunkSyncKeywordRetry": "Retrying keyword index ({attempt}/{maxAttempts})",
|
||||
"chunkSyncIndexRetry": "Retrying search index ({attempt}/{maxAttempts})",
|
||||
"chunkSyncSucceeded": "Search index updated",
|
||||
"chunkSyncFailed": "Index sync failed. Click to retry",
|
||||
"chunkSyncRetryFailed": "Failed to retry index sync",
|
||||
|
||||
@@ -282,6 +282,10 @@
|
||||
"discardChanges": "放弃修改",
|
||||
"chunkSourceFallback": "已切换到源码编辑",
|
||||
"chunkSyncPending": "正在更新检索索引",
|
||||
"chunkSyncEmbeddingRetry": "向量模型服务重试中({attempt}/{maxAttempts})",
|
||||
"chunkSyncVectorRetry": "向量索引重试中({attempt}/{maxAttempts})",
|
||||
"chunkSyncKeywordRetry": "关键词索引重试中({attempt}/{maxAttempts})",
|
||||
"chunkSyncIndexRetry": "检索索引重试中({attempt}/{maxAttempts})",
|
||||
"chunkSyncSucceeded": "检索索引已更新",
|
||||
"chunkSyncFailed": "索引同步失败,点击重试",
|
||||
"chunkSyncRetryFailed": "索引同步重试失败",
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock('element-plus', async (importOriginal) => {
|
||||
vi.mock('#/api/request', () => ({ api: {} }));
|
||||
|
||||
vi.mock('@easyflow/locales', () => ({
|
||||
$t: (key: string) => {
|
||||
$t: (key: string, params: Record<string, number> = {}) => {
|
||||
const messages: Record<string, string> = {
|
||||
'documentCollection.continueEditing': '继续编辑',
|
||||
'documentCollection.deleteChunk': '删除分块',
|
||||
@@ -54,8 +54,18 @@ vi.mock('@easyflow/locales', () => ({
|
||||
'documentCollection.chunkSyncSucceeded': '检索索引已更新',
|
||||
'documentCollection.chunkSyncFailed': '索引同步失败,点击重试',
|
||||
'documentCollection.chunkSyncRetryFailed': '索引同步重试失败',
|
||||
'documentCollection.chunkSyncEmbeddingRetry':
|
||||
'向量模型服务重试中({attempt}/{maxAttempts})',
|
||||
'documentCollection.chunkSyncVectorRetry':
|
||||
'向量索引重试中({attempt}/{maxAttempts})',
|
||||
'documentCollection.chunkSyncKeywordRetry':
|
||||
'关键词索引重试中({attempt}/{maxAttempts})',
|
||||
'documentCollection.chunkSyncIndexRetry':
|
||||
'检索索引重试中({attempt}/{maxAttempts})',
|
||||
};
|
||||
return messages[key] || key;
|
||||
return (messages[key] || key).replaceAll(/\{(\w+)\}/g, (_, name: string) =>
|
||||
String(params[name]),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -652,6 +662,113 @@ describe('chunkDocumentTable', () => {
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['EMBEDDING_REQUEST_FAILED', '向量模型服务重试中'],
|
||||
['VECTOR_UPSERT_FAILED', '向量索引重试中'],
|
||||
['KEYWORD_UPSERT_FAILED', '关键词索引重试中'],
|
||||
['INDEX_UPSERT_FAILED', '检索索引重试中'],
|
||||
])('轮询展示真实次数并区分重试原因 %s', async (code, label) => {
|
||||
vi.useFakeTimers();
|
||||
const pendingRow = { ...row, indexSyncStatus: 'PENDING' };
|
||||
const post = vi.fn().mockResolvedValue({
|
||||
errorCode: 0,
|
||||
data: [
|
||||
{
|
||||
id: row.id,
|
||||
indexSyncStatus: 'PENDING',
|
||||
indexSyncVersion: row.indexSyncVersion,
|
||||
indexSyncErrorCode: code,
|
||||
indexSyncAttemptCount: 1,
|
||||
indexSyncMaxAttempts: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
const { wrapper } = mountTable(post, [pendingRow]);
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.chunk-sync-retry-hint').exists()).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await flushPromises();
|
||||
expect(wrapper.get('.chunk-sync-retry-hint').text()).toBe(
|
||||
`${label}(1/5)`,
|
||||
);
|
||||
expect(wrapper.get('.chunk-sync-retry-hint').attributes('role')).toBe(
|
||||
'status',
|
||||
);
|
||||
|
||||
post.mockResolvedValue({
|
||||
errorCode: 0,
|
||||
data: [
|
||||
{
|
||||
id: row.id,
|
||||
indexSyncStatus: 'SYNCED',
|
||||
indexSyncVersion: row.indexSyncVersion,
|
||||
},
|
||||
],
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.chunk-sync-retry-hint').exists()).toBe(false);
|
||||
expect(
|
||||
(wrapper.findComponent(PageData).vm as any).getPageRows()[0]
|
||||
.indexSyncErrorCode,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['xlsx', 'pdf'])(
|
||||
'两种分块布局均展示重试提示:%s',
|
||||
async (sourceFileExt) => {
|
||||
const { wrapper } = mountTable(vi.fn(), [
|
||||
{
|
||||
...row,
|
||||
options: {
|
||||
...row.options,
|
||||
sourceFileExt,
|
||||
sheetName: sourceFileExt === 'xlsx' ? 'Sheet1' : undefined,
|
||||
},
|
||||
indexSyncStatus: 'PENDING',
|
||||
indexSyncErrorCode: 'EMBEDDING_REQUEST_FAILED',
|
||||
indexSyncAttemptCount: 2,
|
||||
indexSyncMaxAttempts: 5,
|
||||
},
|
||||
]);
|
||||
await flushPromises();
|
||||
expect(wrapper.get('.chunk-sync-retry-hint').text()).toBe(
|
||||
'向量模型服务重试中(2/5)',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('新版本保存后清除上个版本的重试提示', async () => {
|
||||
const { wrapper } = mountTable(
|
||||
vi.fn().mockResolvedValue({
|
||||
errorCode: 0,
|
||||
data: {
|
||||
content: '新正文',
|
||||
indexSyncStatus: 'PENDING',
|
||||
indexSyncVersion: 2,
|
||||
},
|
||||
}),
|
||||
[
|
||||
{
|
||||
...row,
|
||||
indexSyncStatus: 'PENDING',
|
||||
indexSyncVersion: 1,
|
||||
indexSyncErrorCode: 'EMBEDDING_REQUEST_FAILED',
|
||||
indexSyncAttemptCount: 2,
|
||||
indexSyncMaxAttempts: 5,
|
||||
},
|
||||
],
|
||||
);
|
||||
await flushPromises();
|
||||
await getButton(wrapper, 'button.edit').trigger('click');
|
||||
await flushPromises();
|
||||
await wrapper.get('.editor-stub').setValue('新正文');
|
||||
await getButton(wrapper, 'button.save').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.chunk-sync-retry-hint').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('实时编辑器保真失败时使用同一草稿切换源码模式', async () => {
|
||||
const { wrapper } = mountTable();
|
||||
await flushPromises();
|
||||
|
||||
@@ -153,6 +153,30 @@ const isSyncSuccessVisible = (row: any) =>
|
||||
syncSuccessVersions.value[String(row?.id || '')] ===
|
||||
String(row?.indexSyncVersion ?? '');
|
||||
|
||||
const getSyncRetryLabel = (row: any) => {
|
||||
const attempt = Number(row?.indexSyncAttemptCount);
|
||||
const maxAttempts = Number(row?.indexSyncMaxAttempts);
|
||||
if (
|
||||
row?.indexSyncStatus !== 'PENDING' ||
|
||||
!row.indexSyncErrorCode ||
|
||||
!Number.isInteger(attempt) ||
|
||||
attempt <= 0 ||
|
||||
!Number.isInteger(maxAttempts) ||
|
||||
maxAttempts <= 0
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
const retryKeys: Record<string, string> = {
|
||||
EMBEDDING_REQUEST_FAILED: 'chunkSyncEmbeddingRetry',
|
||||
KEYWORD_UPSERT_FAILED: 'chunkSyncKeywordRetry',
|
||||
VECTOR_UPSERT_FAILED: 'chunkSyncVectorRetry',
|
||||
};
|
||||
return $t(
|
||||
`documentCollection.${retryKeys[row.indexSyncErrorCode] || 'chunkSyncIndexRetry'}`,
|
||||
{ attempt, maxAttempts },
|
||||
);
|
||||
};
|
||||
|
||||
const getChunkOptions = (row: any) => row?.options || {};
|
||||
const getRawMarkdown = (row: any) =>
|
||||
String(getChunkOptions(row)?.renderMarkdown ?? row?.content ?? '');
|
||||
@@ -323,6 +347,8 @@ const updateRow = (row: any, updated: any) => {
|
||||
content: updated?.content ?? row.content,
|
||||
indexSyncErrorCode: updated?.indexSyncErrorCode ?? null,
|
||||
indexSyncErrorMessage: updated?.indexSyncErrorMessage ?? null,
|
||||
indexSyncAttemptCount: updated?.indexSyncAttemptCount ?? null,
|
||||
indexSyncMaxAttempts: updated?.indexSyncMaxAttempts ?? null,
|
||||
indexSyncStatus: nextSyncStatus,
|
||||
indexSyncVersion: nextSyncVersion,
|
||||
options: updated?.options ?? {
|
||||
@@ -390,7 +416,13 @@ const pollSyncStatus = async (generation: number) => {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
pageDataRef.value?.patchRowById?.(status.id, status);
|
||||
pageDataRef.value?.patchRowById?.(status.id, {
|
||||
...status,
|
||||
indexSyncAttemptCount: status.indexSyncAttemptCount ?? null,
|
||||
indexSyncMaxAttempts: status.indexSyncMaxAttempts ?? null,
|
||||
indexSyncErrorCode: status.indexSyncErrorCode ?? null,
|
||||
indexSyncErrorMessage: status.indexSyncErrorMessage ?? null,
|
||||
});
|
||||
if (status.indexSyncStatus === 'SYNCED') {
|
||||
showSyncSuccessFeedback(status);
|
||||
} else {
|
||||
@@ -458,6 +490,8 @@ const retrySync = async (row: any) => {
|
||||
pageDataRef.value?.patchRowById?.(row.id, {
|
||||
indexSyncErrorCode: res.data?.indexSyncErrorCode ?? null,
|
||||
indexSyncErrorMessage: res.data?.indexSyncErrorMessage ?? null,
|
||||
indexSyncAttemptCount: res.data?.indexSyncAttemptCount ?? null,
|
||||
indexSyncMaxAttempts: res.data?.indexSyncMaxAttempts ?? null,
|
||||
indexSyncStatus: res.data?.indexSyncStatus,
|
||||
indexSyncVersion: res.data?.indexSyncVersion,
|
||||
});
|
||||
@@ -785,6 +819,13 @@ const getChunkHeaderLabel = (row: any) => {
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="getSyncRetryLabel(row)"
|
||||
class="chunk-sync-retry-hint text-xs"
|
||||
role="status"
|
||||
>
|
||||
{{ getSyncRetryLabel(row) }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="isEditing(row)"
|
||||
@@ -1033,6 +1074,13 @@ const getChunkHeaderLabel = (row: any) => {
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<span
|
||||
v-if="getSyncRetryLabel(row)"
|
||||
class="chunk-sync-retry-hint text-xs"
|
||||
role="status"
|
||||
>
|
||||
{{ getSyncRetryLabel(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
@@ -1089,6 +1137,21 @@ const getChunkHeaderLabel = (row: any) => {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.chunk-sync-retry-hint {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
margin-top: var(--space-1);
|
||||
font-weight: normal;
|
||||
color: hsl(var(--text-muted));
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chunk-item > .chunk-sync-retry-hint {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.chunk-sync-status {
|
||||
display: inline-grid;
|
||||
flex: 0 0 24px;
|
||||
|
||||
Reference in New Issue
Block a user