feat(client): back off digital employee sync retries

This commit is contained in:
baiyanyun
2026-06-05 18:19:32 +08:00
parent 35d2cb5ea5
commit b7aecad092
3 changed files with 31 additions and 14 deletions

View File

@@ -1,11 +1,14 @@
import log from "electron-log";
import { getDeviceId } from "../system/deviceId";
import { getDomainTokenKey } from "@shared/utils/domain";
import { getDb, readSetting } from "../../db";
const DEFAULT_SYNC_PATH = "/api/digital-employee/sync/outbox";
const DEFAULT_INTERVAL_MS = 30_000;
const DEFAULT_BATCH_SIZE = 50;
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 syncing = false;
@@ -27,6 +30,7 @@ interface OutboxRow {
operation: string;
payload: string;
attempts: number;
last_attempt_at?: string | null;
}
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();
const config = resolveSyncConfig();
@@ -102,7 +106,7 @@ export async function flushDigitalEmployeeSyncOutbox(): Promise<DigitalEmployeeS
return getDigitalEmployeeSyncStatus();
}
const rows = readPendingOutbox(config.batchSize);
const rows = readPendingOutbox(config.batchSize, options.force === true);
if (rows.length === 0) {
lastSyncError = null;
return getDigitalEmployeeSyncStatus();
@@ -198,8 +202,7 @@ function readAuthToken(serverHost: string | null): string | null {
return oneShotToken;
}
if (serverHost) {
const domain = serverHost.replace(/^https?:\/\//, "").replace(/\/+$/, "");
const domainToken = readSetting(`auth.tokens.${domain}`);
const domainToken = readSetting(getDomainTokenKey(serverHost));
if (typeof domainToken === "string" && domainToken.trim()) {
return domainToken;
}
@@ -207,20 +210,35 @@ function readAuthToken(serverHost: string | null): string | null {
return null;
}
function readPendingOutbox(limit: number): OutboxRow[] {
function readPendingOutbox(limit: number, force: boolean): OutboxRow[] {
const db = getDb();
if (!db) return [];
return db
const candidateLimit = force ? limit : limit * FAILED_RETRY_CANDIDATE_MULTIPLIER;
const rows = db
.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
WHERE status IN ('pending', 'failed')
ORDER BY created_at ASC
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(