feat(client): back off digital employee sync retries
This commit is contained in:
@@ -40,7 +40,7 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("digitalEmployee:flushSync", async () => {
|
ipcMain.handle("digitalEmployee:flushSync", async () => {
|
||||||
return flushDigitalEmployeeSyncOutbox();
|
return flushDigitalEmployeeSyncOutbox({ force: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import log from "electron-log";
|
import log from "electron-log";
|
||||||
import { getDeviceId } from "../system/deviceId";
|
import { getDeviceId } from "../system/deviceId";
|
||||||
|
import { getDomainTokenKey } from "@shared/utils/domain";
|
||||||
import { getDb, readSetting } from "../../db";
|
import { getDb, readSetting } from "../../db";
|
||||||
|
|
||||||
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
|
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
|
||||||
const DEFAULT_INTERVAL_MS = 30_000;
|
const DEFAULT_INTERVAL_MS = 30_000;
|
||||||
const DEFAULT_BATCH_SIZE = 50;
|
const DEFAULT_BATCH_SIZE = 50;
|
||||||
const REQUEST_TIMEOUT_MS = 15_000;
|
const REQUEST_TIMEOUT_MS = 15_000;
|
||||||
|
const MAX_RETRY_DELAY_MS = 30 * 60_000;
|
||||||
|
const FAILED_RETRY_CANDIDATE_MULTIPLIER = 4;
|
||||||
|
|
||||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let syncing = false;
|
let syncing = false;
|
||||||
@@ -27,6 +30,7 @@ interface OutboxRow {
|
|||||||
operation: string;
|
operation: string;
|
||||||
payload: string;
|
payload: string;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
|
last_attempt_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncAck {
|
interface SyncAck {
|
||||||
@@ -93,7 +97,7 @@ export function getDigitalEmployeeSyncStatus(): DigitalEmployeeSyncStatus {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function flushDigitalEmployeeSyncOutbox(): Promise<DigitalEmployeeSyncStatus> {
|
export async function flushDigitalEmployeeSyncOutbox(options: { force?: boolean } = {}): Promise<DigitalEmployeeSyncStatus> {
|
||||||
if (syncing) return getDigitalEmployeeSyncStatus();
|
if (syncing) return getDigitalEmployeeSyncStatus();
|
||||||
|
|
||||||
const config = resolveSyncConfig();
|
const config = resolveSyncConfig();
|
||||||
@@ -102,7 +106,7 @@ export async function flushDigitalEmployeeSyncOutbox(): Promise<DigitalEmployeeS
|
|||||||
return getDigitalEmployeeSyncStatus();
|
return getDigitalEmployeeSyncStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = readPendingOutbox(config.batchSize);
|
const rows = readPendingOutbox(config.batchSize, options.force === true);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
lastSyncError = null;
|
lastSyncError = null;
|
||||||
return getDigitalEmployeeSyncStatus();
|
return getDigitalEmployeeSyncStatus();
|
||||||
@@ -198,8 +202,7 @@ function readAuthToken(serverHost: string | null): string | null {
|
|||||||
return oneShotToken;
|
return oneShotToken;
|
||||||
}
|
}
|
||||||
if (serverHost) {
|
if (serverHost) {
|
||||||
const domain = serverHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
const domainToken = readSetting(getDomainTokenKey(serverHost));
|
||||||
const domainToken = readSetting(`auth.tokens.${domain}`);
|
|
||||||
if (typeof domainToken === "string" && domainToken.trim()) {
|
if (typeof domainToken === "string" && domainToken.trim()) {
|
||||||
return domainToken;
|
return domainToken;
|
||||||
}
|
}
|
||||||
@@ -207,20 +210,35 @@ function readAuthToken(serverHost: string | null): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPendingOutbox(limit: number): OutboxRow[] {
|
function readPendingOutbox(limit: number, force: boolean): OutboxRow[] {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
if (!db) return [];
|
if (!db) return [];
|
||||||
return db
|
const candidateLimit = force ? limit : limit * FAILED_RETRY_CANDIDATE_MULTIPLIER;
|
||||||
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT id, entity_type, entity_id, operation, payload, attempts
|
SELECT id, entity_type, entity_id, operation, payload, attempts, last_attempt_at
|
||||||
FROM digital_sync_outbox
|
FROM digital_sync_outbox
|
||||||
WHERE status IN ('pending', 'failed')
|
WHERE status IN ('pending', 'failed')
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.all(limit) as OutboxRow[];
|
.all(candidateLimit) as OutboxRow[];
|
||||||
|
if (force) return rows.slice(0, limit);
|
||||||
|
return rows.filter(isOutboxRowDueForRetry).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutboxRowDueForRetry(row: OutboxRow): boolean {
|
||||||
|
if (!row.last_attempt_at) return true;
|
||||||
|
const lastAttemptAt = Date.parse(row.last_attempt_at);
|
||||||
|
if (!Number.isFinite(lastAttemptAt)) return true;
|
||||||
|
return Date.now() >= lastAttemptAt + retryDelayMs(row.attempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryDelayMs(attempts: number): number {
|
||||||
|
const exponent = Math.max(0, Math.min(attempts - 1, 10));
|
||||||
|
return Math.min(DEFAULT_INTERVAL_MS * 2 ** exponent, MAX_RETRY_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postOutbox(
|
async function postOutbox(
|
||||||
|
|||||||
@@ -439,10 +439,10 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 可通过 settings `digital_employee_sync_config.endpoint` 覆盖同步地址。
|
- 可通过 settings `digital_employee_sync_config.endpoint` 覆盖同步地址。
|
||||||
- 使用 `auth.saved_key` 或域名级 saved key 作为客户端凭证。
|
- 使用 `auth.saved_key` 或域名级 saved key 作为客户端标识。
|
||||||
- 请求同时携带 `Authorization: Bearer <clientKey>` 和 `X-Qiming-Client-Key`,兼容后端后续选择。
|
- 使用管理端登录态 token 作为 `Authorization: Bearer <token>`,同时携带 `X-Qiming-Client-Key`。
|
||||||
- 同步成功后回填 outbox 状态,并根据后端 ack 回填实体 `remote_id` / `last_synced_at`。
|
- 同步成功后回填 outbox 状态,并根据后端 ack 回填实体 `remote_id` / `last_synced_at`。
|
||||||
- 同步失败时标记 failed 和错误信息,下轮继续补偿,不影响 `/computer/chat` 和 ACP 执行链路。
|
- 同步失败时标记 failed 和错误信息;定时 worker 按指数退避补偿,手动 `flushSync` 可强制立即重试,不影响 `/computer/chat` 和 ACP 执行链路。
|
||||||
|
|
||||||
新增 IPC / Bridge:
|
新增 IPC / Bridge:
|
||||||
|
|
||||||
@@ -472,8 +472,7 @@ qiming-backend/app-platform-modules/app-platform-agent/app-platform-agent-core-a
|
|||||||
下一步:
|
下一步:
|
||||||
|
|
||||||
- 在有 Maven 的环境验证 qiming-backend 编译与接口启动。
|
- 在有 Maven 的环境验证 qiming-backend 编译与接口启动。
|
||||||
- 数字员工页面读取 `getSyncStatus()`,展示联动状态。
|
- 数字员工页面继续细化同步状态交互,例如手动同步按钮和失败原因展开。
|
||||||
- 增加 outbox 重试退避策略,避免接口长期不可用时频繁请求。
|
|
||||||
|
|
||||||
## 管理端联动原则
|
## 管理端联动原则
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user