From 211e913501b2389e24aaf0a7dbf2a4df19e85108 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 14 Sep 2026 20:55:55 +0100 Subject: [PATCH] a restart mid-sync stranded accounts at syncing or queued forever getAllSyncedAccounts only picks up status = synced, so an account whose sidecar process died between marking it syncing/queued and clearing that status again dropped out of the cron and the IDLE watcher silently, with no error to explain why background sync had stopped. Reset both on startup instead: the job runner is in-memory, so neither state can be genuinely in flight in a fresh process. --- db/queries.ts | 20 +++++++++++++++++++- sidecar/email-cron.ts | 5 ++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/db/queries.ts b/db/queries.ts index 92074a1..32a4918 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -1,4 +1,4 @@ -import { eq, and } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { emailAccounts } from './schema'; @@ -71,6 +71,24 @@ export async function getAllSyncedAccounts(): Promise { return rows.map(open); } +/** + * Clear a `syncing` or `queued` status left behind by a process that died mid-sync. + * + * `getAllSyncedAccounts` only picks up `status = 'synced'`, so an account stranded at either — the + * resync route sets `syncing` before awaiting and only clears it after; a first sync sets `queued` + * before handing off to the in-process job runner — silently drops out of both the cron and the IDLE + * watcher forever. A fresh process can't have either genuinely in flight (the job runner is in-memory, + * not persisted), so on startup that state only ever means "orphaned by the last restart." `performResync` + * — what the cron and IDLE watcher call — already handles an account with no prior sync state by doing a + * full fetch, so resetting to `synced` rather than replaying the dedicated first-sync job is safe. + */ +export async function resetStuckSyncingAccounts(): Promise { + await db + .update(emailAccounts) + .set({ status: 'synced' }) + .where(inArray(emailAccounts.status, ['syncing', 'queued'])); +} + /** Replace an account's stored secret — used when an OAuth token is refreshed. */ export async function updateEmailAccountCredentials(id: number, credentials: Record): Promise { await db.update(emailAccounts).set({ credentials: seal(credentials) }).where(eq(emailAccounts.id, id)); diff --git a/sidecar/email-cron.ts b/sidecar/email-cron.ts index b20f6b6..c933e1b 100644 --- a/sidecar/email-cron.ts +++ b/sidecar/email-cron.ts @@ -1,5 +1,5 @@ import { getUserById } from 'officerdb'; -import { getAllSyncedAccounts } from '../db/queries'; +import { getAllSyncedAccounts, resetStuckSyncingAccounts } from '../db/queries'; import { performResync } from './resync'; const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes @@ -32,6 +32,9 @@ async function tick() { export function initEmailCron() { if (timer) return; + resetStuckSyncingAccounts().catch((err) => { + console.error('[email-cron] Failed to reset stuck accounts:', err instanceof Error ? err.message : err); + }); console.log(`[email-cron] Starting email sync cron (every ${INTERVAL_MS / 60_000} min)`); timer = setInterval(tick, INTERVAL_MS); setTimeout(tick, 30_000);