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.
This commit is contained in:
2026-09-14 20:56:24 +01:00
parent fda9beb80a
commit 211e913501
2 changed files with 23 additions and 2 deletions
+19 -1
View File
@@ -1,4 +1,4 @@
import { eq, and } from 'drizzle-orm'; import { eq, and, inArray } from 'drizzle-orm';
import { db } from 'officerdb/db'; import { db } from 'officerdb/db';
import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { encryptSecret, decryptSecret } from 'officerdb/crypto';
import { emailAccounts } from './schema'; import { emailAccounts } from './schema';
@@ -71,6 +71,24 @@ export async function getAllSyncedAccounts(): Promise<EmailAccount[]> {
return rows.map(open); 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<void> {
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. */ /** Replace an account's stored secret — used when an OAuth token is refreshed. */
export async function updateEmailAccountCredentials(id: number, credentials: Record<string, unknown>): Promise<void> { export async function updateEmailAccountCredentials(id: number, credentials: Record<string, unknown>): Promise<void> {
await db.update(emailAccounts).set({ credentials: seal(credentials) }).where(eq(emailAccounts.id, id)); await db.update(emailAccounts).set({ credentials: seal(credentials) }).where(eq(emailAccounts.id, id));
+4 -1
View File
@@ -1,5 +1,5 @@
import { getUserById } from 'officerdb'; import { getUserById } from 'officerdb';
import { getAllSyncedAccounts } from '../db/queries'; import { getAllSyncedAccounts, resetStuckSyncingAccounts } from '../db/queries';
import { performResync } from './resync'; import { performResync } from './resync';
const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
@@ -32,6 +32,9 @@ async function tick() {
export function initEmailCron() { export function initEmailCron() {
if (timer) return; 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)`); console.log(`[email-cron] Starting email sync cron (every ${INTERVAL_MS / 60_000} min)`);
timer = setInterval(tick, INTERVAL_MS); timer = setInterval(tick, INTERVAL_MS);
setTimeout(tick, 30_000); setTimeout(tick, 30_000);