feat(client): track digital sync retry history

This commit is contained in:
baiyanyun
2026-06-07 01:42:39 +08:00
parent 646901b5fa
commit cef3fe4a43
10 changed files with 375 additions and 133 deletions

View File

@@ -11,6 +11,7 @@ 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;
const ATTEMPT_HISTORY_RETENTION_MS = 30 * 24 * 60 * 60_000;
let syncTimer: ReturnType<typeof setInterval> | null = null;
let syncing = false;
@@ -35,6 +36,15 @@ interface OutboxRow {
last_attempt_at?: string | null;
}
export interface DigitalEmployeeSyncAttempt {
attemptNo: number;
status: string;
error: string | null;
endpoint: string | null;
startedAt: string;
finishedAt: string;
}
export interface DigitalEmployeeSyncFailure {
id: string;
entityType: string;
@@ -45,6 +55,7 @@ export interface DigitalEmployeeSyncFailure {
nextRetryAt: string | null;
dueForRetry: boolean;
managementRecordUrl: string | null;
attemptHistory: DigitalEmployeeSyncAttempt[];
error: string | null;
createdAt: string;
updatedAt: string;
@@ -134,17 +145,25 @@ export async function flushDigitalEmployeeSyncOutbox(
}
syncing = true;
const now = new Date().toISOString();
markRowsSyncing(rows, now);
const startedAt = new Date().toISOString();
markRowsSyncing(rows, startedAt);
try {
const response = await postOutbox(config, rows);
const finishedAt = new Date().toISOString();
const acked = response.data?.acked || response.acked || [];
if (response.success === false) {
throw new Error(response.message || "digital employee sync rejected");
}
const syncedRows = acked.length > 0
? rows.filter((row) => acked.some((ack) => ackMatchesRow(ack, row)))
: rows;
const unackedRows = acked.length > 0
? rows.filter((row) => !acked.some((ack) => ackMatchesRow(ack, row)))
: [];
recordSyncAttempts(syncedRows, config, "synced", null, startedAt, finishedAt);
if (acked.length > 0) {
markRowsSynced(acked, now);
markRowsSynced(acked, finishedAt);
} else {
markRowsSynced(
rows.map((row) => ({
@@ -152,14 +171,30 @@ export async function flushDigitalEmployeeSyncOutbox(
entity_type: row.entity_type,
entity_id: row.entity_id,
})),
now,
finishedAt,
);
}
lastSyncAt = now;
lastSyncError = null;
if (unackedRows.length > 0) {
const unackedError = "management sync response did not acknowledge item";
recordSyncAttempts(
unackedRows,
config,
"failed",
unackedError,
startedAt,
finishedAt,
);
markRowsFailed(unackedRows, finishedAt, unackedError);
lastSyncError = unackedError;
} else {
lastSyncError = null;
}
lastSyncAt = finishedAt;
} catch (error) {
const finishedAt = new Date().toISOString();
lastSyncError = normalizeError(error);
markRowsFailed(rows, now, lastSyncError);
recordSyncAttempts(rows, config, "failed", lastSyncError, startedAt, finishedAt);
markRowsFailed(rows, finishedAt, lastSyncError);
log.warn("[DigitalEmployeeSync] Flush failed:", lastSyncError);
} finally {
syncing = false;
@@ -335,6 +370,50 @@ function markRowsSynced(acks: SyncAck[], now: string): void {
tx();
}
function ackMatchesRow(ack: SyncAck, row: OutboxRow): boolean {
const outboxId = ack.outbox_id || ack.id;
if (outboxId) return outboxId === row.id;
return ack.entity_type === row.entity_type && ack.entity_id === row.entity_id;
}
function recordSyncAttempts(
rows: OutboxRow[],
config: DigitalEmployeeSyncConfig,
status: "synced" | "failed",
error: string | null,
startedAt: string,
finishedAt: string,
): void {
const db = getDb();
if (!db) return;
const tx = db.transaction(() => {
const stmt = db.prepare(`
INSERT INTO digital_sync_attempts (
outbox_id, entity_type, entity_id, operation, attempt_no,
status, error, endpoint, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const row of rows) {
stmt.run(
row.id,
row.entity_type,
row.entity_id,
row.operation,
row.attempts + 1,
status,
error,
config.endpoint,
startedAt,
finishedAt,
);
}
db.prepare("DELETE FROM digital_sync_attempts WHERE finished_at < ?").run(
new Date(Date.parse(finishedAt) - ATTEMPT_HISTORY_RETENTION_MS).toISOString(),
);
});
tx();
}
function updateEntitySyncStatus(ack: SyncAck, now: string): void {
if (!ack.entity_type || !ack.entity_id) return;
const table = entityTable(ack.entity_type);
@@ -429,6 +508,7 @@ function readRecentFailures(
nextRetryAt,
dueForRetry: !nextRetryAt || Date.now() >= Date.parse(nextRetryAt),
managementRecordUrl: buildManagementRecordUrl(config, row),
attemptHistory: readAttemptHistory(row.id),
error: row.error,
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -436,6 +516,41 @@ function readRecentFailures(
});
}
function readAttemptHistory(
outboxId: string,
limit = 5,
): DigitalEmployeeSyncAttempt[] {
const db = getDb();
if (!db) return [];
const rows = db
.prepare(
`
SELECT attempt_no, status, error, endpoint, started_at, finished_at
FROM digital_sync_attempts
WHERE outbox_id = ?
ORDER BY finished_at DESC, id DESC
LIMIT ?
`,
)
.all(outboxId, limit) as Array<{
attempt_no: number;
status: string;
error: string | null;
endpoint: string | null;
started_at: string;
finished_at: string;
}>;
return rows.map((row) => ({
attemptNo: row.attempt_no,
status: row.status,
error: row.error,
endpoint: row.endpoint,
startedAt: row.started_at,
finishedAt: row.finished_at,
}));
}
function buildManagementRecordUrl(
config: DigitalEmployeeSyncConfig,
row: {