fix: 完善自动导入异常中断与恢复
- 自动导入基础设施异常触发批次熔断,保留完整日志并输出安全错误信息 - 增加恢复令牌与租约围栏、无文档失败项重建及消息退避机制 - 前端展示中断状态并在状态请求失败后自动恢复轮询 - 补充批次中断迁移、配置与并发异常路径测试
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -116,6 +116,10 @@
|
||||
"pendingCount": "等待",
|
||||
"skippedCount": "跳过",
|
||||
"continueBatch": "继续",
|
||||
"batchStatusLoadFailed": "自动导入状态暂时无法获取,将自动重试",
|
||||
"interruptFallback": "自动导入发生异常,批次已中断,请确认服务恢复后继续",
|
||||
"interruptCode": "错误标识",
|
||||
"interruptedAt": "中断时间",
|
||||
"splitOrIndexFailed": "分块或向量化失败,请重试",
|
||||
"documentSourceUnavailable": "文档文件读取失败,请联系管理员",
|
||||
"parseServiceUnavailable": "文档解析服务暂不可用,请稍后重试",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user