feat(client): track digital sync retry history
This commit is contained in:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user