fix: 完善自动导入异常中断与恢复

- 自动导入基础设施异常触发批次熔断,保留完整日志并输出安全错误信息

- 增加恢复令牌与租约围栏、无文档失败项重建及消息退避机制

- 前端展示中断状态并在状态请求失败后自动恢复轮询

- 补充批次中断迁移、配置与并发异常路径测试
This commit is contained in:
2026-08-10 11:26:00 +08:00
parent 54d85ae460
commit bae7b18977
33 changed files with 3725 additions and 221 deletions

View File

@@ -59,4 +59,35 @@ describe('page data recovery', () => {
expect(get).toHaveBeenCalledTimes(2);
expect(wrapper.text()).not.toContain('数据加载失败,请重试');
});
it('coalesces repeated reloads while a page request is still running', async () => {
let resolveInitialRequest: (value: {
data: { records: never[]; totalRow: number };
}) => void = () => {};
const initialRequest = new Promise<{
data: { records: never[]; totalRow: number };
}>((resolve) => {
resolveInitialRequest = resolve;
});
const get = vi
.fn()
.mockReturnValueOnce(initialRequest)
.mockResolvedValue({ data: { records: [], totalRow: 0 } });
const wrapper = mount(PageData, {
global: {
directives: { loading: {} },
},
props: { pageUrl: '/page', requestClient: { get } },
});
const firstReload = (wrapper.vm as any).reload({ silent: true });
const secondReload = (wrapper.vm as any).reload({ silent: true });
expect(get).toHaveBeenCalledTimes(1);
resolveInitialRequest({ data: { records: [], totalRow: 0 } });
await Promise.all([firstReload, secondReload]);
await flushPromises();
expect(get).toHaveBeenCalledTimes(2);
});
});

View File

@@ -24,6 +24,15 @@ interface PageDataState {
pageSize: number;
}
interface PageDataReloadOptions {
silent?: boolean;
}
interface PageDataRequest {
silent: boolean;
version: number;
}
const props = withDefaults(defineProps<PageDataProps>(), {
pageSize: 10,
pageSizes: () => [10, 20, 50, 100],
@@ -41,7 +50,9 @@ const pageList = ref<PageDataRow[]>([]);
const loading = ref(false);
const loadError = ref<unknown>();
const queryParams = ref<Record<string, any>>({ ...props.initialQueryParams });
let pageRequest = 0;
let activePageRequest: null | Promise<void> = null;
let pendingPageRequest: null | PageDataRequest = null;
let pageRequestVersion = 0;
const pageInfo = reactive({
pageNumber: Math.max(1, Math.trunc(props.initialPageNumber)),
@@ -60,11 +71,7 @@ const doGet = async (params: Record<string, any>) => {
return { data };
};
// 获取页面数据
const getPageList = async () => {
const request = ++pageRequest;
loading.value = true;
loadError.value = undefined;
const loadPageListOnce = async (request: PageDataRequest) => {
try {
const res = await doGet({
pageNumber: pageInfo.pageNumber,
@@ -72,21 +79,61 @@ const getPageList = async () => {
...props.extraQueryParams,
...queryParams.value,
});
if (request === pageRequest) {
if (request.version === pageRequestVersion) {
pageList.value = res.data?.records || [];
pageInfo.total = res.data?.totalRow || 0;
}
} catch (error) {
if (request === pageRequest) {
if (request.version === pageRequestVersion) {
loadError.value = error;
pageList.value = [];
pageInfo.total = 0;
if (!request.silent || pageList.value.length === 0) {
pageList.value = [];
pageInfo.total = 0;
}
}
} finally {
if (request === pageRequest) loading.value = false;
}
};
const requestPageList = (silent: boolean) => {
const request: PageDataRequest = {
silent: silent && pageList.value.length > 0,
version: ++pageRequestVersion,
};
loadError.value = undefined;
if (!request.silent) {
loading.value = true;
}
if (activePageRequest) {
pendingPageRequest = pendingPageRequest
? {
silent: pendingPageRequest.silent && request.silent,
version: request.version,
}
: request;
return activePageRequest;
}
const drainPageRequests = async () => {
let currentRequest: null | PageDataRequest = request;
while (currentRequest) {
pendingPageRequest = null;
await loadPageListOnce(currentRequest);
currentRequest = pendingPageRequest;
}
};
activePageRequest = drainPageRequests().finally(() => {
activePageRequest = null;
loading.value = false;
});
return activePageRequest;
};
// 获取页面数据
const getPageList = () => requestPageList(false);
const reload = (options: PageDataReloadOptions = {}) =>
requestPageList(Boolean(options.silent));
// 分页事件处理
const handleSizeChange = (newSize: number) => {
pageInfo.pageSize = newSize;
@@ -138,7 +185,7 @@ const setQuery = (newQueryParams: Record<string, any>) => {
// 暴露方法给父组件
defineExpose({
getPageState,
reload: getPageList,
reload,
patchRowById,
setQuery,
});

View File

@@ -116,6 +116,10 @@
"pendingCount": "Pending",
"skippedCount": "Skipped",
"continueBatch": "Continue",
"batchStatusLoadFailed": "Unable to load the automatic import status. Retrying automatically.",
"interruptFallback": "Automatic import was interrupted. Resume after the service recovers.",
"interruptCode": "Error code",
"interruptedAt": "Interrupted at",
"splitOrIndexFailed": "Chunking or indexing failed. Please retry.",
"documentSourceUnavailable": "Unable to read the document file. Please contact the administrator.",
"parseServiceUnavailable": "The document parsing service is temporarily unavailable. Please retry later.",

View File

@@ -116,6 +116,10 @@
"pendingCount": "等待",
"skippedCount": "跳过",
"continueBatch": "继续",
"batchStatusLoadFailed": "自动导入状态暂时无法获取,将自动重试",
"interruptFallback": "自动导入发生异常,批次已中断,请确认服务恢复后继续",
"interruptCode": "错误标识",
"interruptedAt": "中断时间",
"splitOrIndexFailed": "分块或向量化失败,请重试",
"documentSourceUnavailable": "文档文件读取失败,请联系管理员",
"parseServiceUnavailable": "文档解析服务暂不可用,请稍后重试",

View File

@@ -153,4 +153,95 @@ describe('documentImportBatchStatus', () => {
expect(wrapper.find('.batch-status__continue').exists()).toBe(false);
wrapper.unmount();
});
it('中断批次展示异常原因、错误标识和继续入口', async () => {
apiMocks.get.mockResolvedValue({
data: {
...createBatch('RUNNING', 0),
failedCount: 2,
interruptCode: 'redis_unavailable',
interruptedAt: '2026-08-07T09:25:28+08:00',
interruptMessage: '缓存与消息服务异常,自动导入已中断',
pendingCount: 0,
status: 'INTERRUPTED',
},
errorCode: 0,
});
const wrapper = mount(DocumentImportBatchStatus, {
props: {
knowledgeId: 'knowledge-1',
manageable: true,
},
});
await flushPromises();
const alert = wrapper.find('.batch-status__alert');
expect(alert.exists()).toBe(true);
expect(alert.text()).toContain('缓存与消息服务异常,自动导入已中断');
expect(alert.text()).toContain('redis_unavailable');
expect(wrapper.find('.batch-status__count').text()).toContain('2 / 2');
expect(wrapper.find('.batch-status__continue').exists()).toBe(true);
wrapper.unmount();
});
it('状态接口暂时失败时展示错误并自动退避恢复轮询', async () => {
vi.useFakeTimers();
apiMocks.get
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce({
data: createBatch('RUNNING', 1),
errorCode: 0,
});
const wrapper = mount(DocumentImportBatchStatus, {
props: { knowledgeId: 'knowledge-1' },
});
await flushPromises();
expect(wrapper.find('.batch-status--error').exists()).toBe(true);
await vi.advanceTimersByTimeAsync(3000);
await flushPromises();
expect(apiMocks.get).toHaveBeenCalledTimes(2);
expect(wrapper.find('.batch-status--error').exists()).toBe(false);
expect(wrapper.find('.batch-status').exists()).toBe(true);
wrapper.unmount();
});
it('运行批次轮询失败时保留旧状态、展示错误并自动恢复', async () => {
vi.useFakeTimers();
apiMocks.get
.mockResolvedValueOnce({
data: createBatch('RUNNING', 1),
errorCode: 0,
})
.mockRejectedValueOnce(new Error('network unavailable'))
.mockResolvedValueOnce({
data: createBatch('COMPLETED', 2),
errorCode: 0,
});
const wrapper = mount(DocumentImportBatchStatus, {
props: { knowledgeId: 'knowledge-1' },
});
await flushPromises();
await vi.advanceTimersByTimeAsync(3000);
await flushPromises();
expect(wrapper.find('.batch-status__summary').exists()).toBe(true);
expect(wrapper.find('.batch-status__load-error').exists()).toBe(true);
await vi.advanceTimersByTimeAsync(3000);
await flushPromises();
expect(apiMocks.get).toHaveBeenCalledTimes(3);
expect(wrapper.find('.batch-status__load-error').exists()).toBe(false);
expect(wrapper.text()).toContain(
'documentCollection.importDoc.batchCompleted',
);
wrapper.unmount();
});
});

View File

@@ -3,15 +3,19 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { $t } from '@easyflow/locales';
import { ElButton, ElProgress } from 'element-plus';
import { ElAlert, ElButton, ElProgress } from 'element-plus';
import { api } from '#/api/request';
interface BatchStatus {
batchId: string;
cancelledCount?: number;
completedCount: number;
failedCount: number;
importMode: 'AUTO' | 'MANUAL';
interruptCode?: string;
interruptedAt?: string;
interruptMessage?: string;
pendingCount: number;
processingCount: number;
progressPercent: number;
@@ -43,9 +47,12 @@ const props = defineProps({
const emit = defineEmits(['continued']);
const batch = ref<BatchStatus>();
const continuing = ref(false);
const loadError = ref('');
const refreshing = ref(false);
let pollTimer: null | ReturnType<typeof setTimeout> = null;
let disposed = false;
let refreshGeneration = 0;
let pollDelayMs = 3000;
const canContinue = computed(
() =>
@@ -55,11 +62,15 @@ const canContinue = computed(
Number(batch.value?.failedCount || 0) > 0,
);
const processedCount = computed(
() =>
const processedCount = computed(() => {
const processed =
Number(batch.value?.completedCount || 0) +
Number(batch.value?.processingCount || 0),
);
Number(batch.value?.processingCount || 0) +
Number(batch.value?.failedCount || 0) +
Number(batch.value?.skippedCount || 0) +
Number(batch.value?.cancelledCount || 0);
return Math.min(Number(batch.value?.totalCount || 0), processed);
});
const allFailed = computed(
() =>
@@ -86,9 +97,35 @@ const statusLabel = computed(() => {
return $t('documentCollection.importDoc.batchRunning');
});
const interruptMetadata = computed(() => {
if (batch.value?.status !== 'INTERRUPTED') {
return '';
}
const details: string[] = [];
if (batch.value.interruptCode) {
details.push(
`${$t('documentCollection.importDoc.interruptCode')} ${
batch.value.interruptCode
}`,
);
}
if (batch.value.interruptedAt) {
const interruptedAt = new Date(batch.value.interruptedAt);
details.push(
`${$t('documentCollection.importDoc.interruptedAt')} ${
Number.isNaN(interruptedAt.getTime())
? batch.value.interruptedAt
: interruptedAt.toLocaleString()
}`,
);
}
return details.join(' · ');
});
async function refresh(hideCompletedOnRestore = false) {
if (!props.knowledgeId) return;
const currentGeneration = ++refreshGeneration;
refreshing.value = true;
try {
const response = await api.get('/api/v1/document/import/batch/current', {
params: { knowledgeId: props.knowledgeId },
@@ -96,14 +133,28 @@ async function refresh(hideCompletedOnRestore = false) {
if (disposed || currentGeneration !== refreshGeneration) {
return;
}
const restoredBatch =
response.errorCode === 0 ? response.data || undefined : undefined;
if (response.errorCode !== 0) {
loadError.value =
response.message ||
$t('documentCollection.importDoc.batchStatusLoadFailed');
return;
}
loadError.value = '';
pollDelayMs = 3000;
const restoredBatch = response.data || undefined;
batch.value =
hideCompletedOnRestore && restoredBatch?.status === 'COMPLETED'
? undefined
: restoredBatch;
} catch {
if (!disposed && currentGeneration === refreshGeneration) {
loadError.value = $t(
'documentCollection.importDoc.batchStatusLoadFailed',
);
}
} finally {
if (!disposed && currentGeneration === refreshGeneration) {
refreshing.value = false;
schedulePoll();
}
}
@@ -112,8 +163,16 @@ async function refresh(hideCompletedOnRestore = false) {
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = null;
if (disposed) {
return;
}
if (loadError.value) {
const retryDelay = pollDelayMs;
pollDelayMs = Math.min(pollDelayMs * 2, 30_000);
pollTimer = setTimeout(refresh, retryDelay);
return;
}
if (
disposed ||
!batch.value ||
batch.value.status === 'COMPLETED' ||
batch.value.status === 'PARTIAL_SUCCEEDED' ||
@@ -160,6 +219,8 @@ watch(
const knowledgeChanged = knowledgeId !== previousKnowledgeId;
if (knowledgeChanged) {
batch.value = undefined;
loadError.value = '';
pollDelayMs = 3000;
}
refresh(knowledgeChanged);
},
@@ -230,6 +291,46 @@ watch(
>
{{ $t('documentCollection.importDoc.continueBatch') }}
</ElButton>
<ElAlert
v-if="batch.status === 'INTERRUPTED'"
class="batch-status__alert"
type="error"
:closable="false"
show-icon
:title="
batch.interruptMessage ||
$t('documentCollection.importDoc.interruptFallback')
"
:description="interruptMetadata"
/>
<div v-if="loadError" class="batch-status__load-error">
<ElAlert
class="batch-status__alert"
type="error"
:closable="false"
show-icon
:title="loadError"
/>
<ElButton type="primary" link :loading="refreshing" @click="refresh()">
{{ $t('documentCollection.importDoc.retry') }}
</ElButton>
</div>
</section>
<section
v-else-if="loadError"
class="batch-status batch-status--error"
:aria-label="$t('documentCollection.importDoc.batchStatus')"
>
<ElAlert
class="batch-status__alert"
type="error"
:closable="false"
show-icon
:title="loadError"
/>
<ElButton type="primary" link :loading="refreshing" @click="refresh()">
{{ $t('documentCollection.importDoc.retry') }}
</ElButton>
</section>
</template>
@@ -237,6 +338,7 @@ watch(
.batch-status {
display: flex;
flex: 1 1 360px;
flex-wrap: wrap;
gap: 12px;
align-items: center;
min-width: min(360px, 100%);
@@ -301,6 +403,25 @@ watch(
min-width: 48px;
}
.batch-status__alert {
flex: 1 0 100%;
}
.batch-status__load-error {
display: flex;
flex: 1 0 100%;
gap: 8px;
align-items: center;
}
.batch-status__load-error .batch-status__alert {
flex-basis: auto;
}
.batch-status--error .batch-status__alert {
flex-basis: 320px;
}
@media (max-width: 900px) {
.batch-status {
flex-basis: 100%;

View File

@@ -85,7 +85,7 @@ const props = defineProps({
const emits = defineEmits(['viewDoc', 'continueProcess']);
const STREAM_RECONNECT_DELAY = 1500;
const STREAM_RELOAD_DELAY = 250;
const STREAM_RELOAD_DELAY = 3000;
const pageDataRef = ref();
const retryingDocumentIds = ref<Set<string>>(new Set());
@@ -254,7 +254,7 @@ const scheduleReload = () => {
}
reloadTimer = setTimeout(() => {
reloadTimer = null;
pageDataRef.value?.reload?.();
pageDataRef.value?.reload?.({ silent: true });
}, STREAM_RELOAD_DELAY);
};
@@ -535,6 +535,7 @@ watch(
if (disposed) {
return;
}
clearReloadTimer();
openTaskStatusStream();
},
);