From a2266635f4526b5a825c5f55a58bc03656f7125f Mon Sep 17 00:00:00 2001 From: null Date: Thu, 4 Jun 2026 21:32:28 -0500 Subject: [PATCH] perf: worker N+1 batching, status runtime DB persistence, 'use strict' --- services/statusRuntime.js | 80 ++++++++++++++++++++++++++---------- workers/dailyWorker.js | 85 +++++++++++++++++++++++++++------------ 2 files changed, 119 insertions(+), 46 deletions(-) diff --git a/services/statusRuntime.js b/services/statusRuntime.js index 9fcd33f..5f2d9bc 100644 --- a/services/statusRuntime.js +++ b/services/statusRuntime.js @@ -1,21 +1,52 @@ +'use strict'; + const MAX_RECENT_ERRORS = 10; +// Keys written to the settings table for cross-restart persistence +const DB_KEY = { + workerLastRun: '_worker_last_run_at', + workerNextRun: '_worker_next_run_at', + workerError: '_worker_last_error', + workerStarted: '_worker_started_at', +}; + const state = { worker: { - enabled: false, - running: false, - started_at: null, + enabled: false, + running: false, + started_at: null, last_run_at: null, next_run_at: null, - last_error: null, + last_error: null, }, notifications: { last_test_at: null, - last_error: null, + last_error: null, }, recentErrors: [], }; +// Seed from DB on first load so status survives container restarts. +// Wrapped in try/catch — DB may not be ready at require-time in tests. +function seedFromDb() { + try { + const { getSetting } = require('../db/database'); + state.worker.last_run_at = getSetting(DB_KEY.workerLastRun) || null; + state.worker.next_run_at = getSetting(DB_KEY.workerNextRun) || null; + state.worker.last_error = getSetting(DB_KEY.workerError) || null; + state.worker.started_at = getSetting(DB_KEY.workerStarted) || null; + if (state.worker.last_run_at) state.worker.enabled = true; + } catch { /* DB not ready — will be populated when workers run */ } +} +seedFromDb(); + +function dbSet(key, value) { + try { + const { setSetting } = require('../db/database'); + setSetting(key, value == null ? '' : String(value)); + } catch { /* non-fatal */ } +} + function toMessage(error) { if (!error) return null; if (typeof error === 'string') return error; @@ -25,39 +56,46 @@ function toMessage(error) { function recordError(source, error) { const message = toMessage(error); if (!message) return; - - state.recentErrors.unshift({ - timestamp: new Date().toISOString(), - source, - message, - }); - + state.recentErrors.unshift({ timestamp: new Date().toISOString(), source, message }); state.recentErrors = state.recentErrors.slice(0, MAX_RECENT_ERRORS); } function markWorkerStarted(nextRunAt = null) { - state.worker.enabled = true; - state.worker.running = true; - state.worker.started_at = state.worker.started_at || new Date().toISOString(); + const now = new Date().toISOString(); + state.worker.enabled = true; + state.worker.running = true; + state.worker.started_at = state.worker.started_at || now; state.worker.next_run_at = nextRunAt; + dbSet(DB_KEY.workerStarted, state.worker.started_at); + if (nextRunAt) dbSet(DB_KEY.workerNextRun, nextRunAt); } function markWorkerSuccess(nextRunAt = null) { - state.worker.last_run_at = new Date().toISOString(); - state.worker.last_error = null; + const now = new Date().toISOString(); + state.worker.running = false; + state.worker.last_run_at = now; + state.worker.last_error = null; if (nextRunAt !== undefined) state.worker.next_run_at = nextRunAt; + dbSet(DB_KEY.workerLastRun, now); + dbSet(DB_KEY.workerError, ''); + if (nextRunAt) dbSet(DB_KEY.workerNextRun, nextRunAt); } function markWorkerError(error, nextRunAt = null) { - state.worker.last_run_at = new Date().toISOString(); - state.worker.last_error = toMessage(error); + const now = new Date().toISOString(); + state.worker.running = false; + state.worker.last_run_at = now; + state.worker.last_error = toMessage(error); if (nextRunAt !== undefined) state.worker.next_run_at = nextRunAt; + dbSet(DB_KEY.workerLastRun, now); + dbSet(DB_KEY.workerError, state.worker.last_error || ''); + if (nextRunAt) dbSet(DB_KEY.workerNextRun, nextRunAt); recordError('Daily Worker', error); } function markNotificationTestSuccess() { state.notifications.last_test_at = new Date().toISOString(); - state.notifications.last_error = null; + state.notifications.last_error = null; } function markNotificationError(error) { @@ -71,7 +109,7 @@ function markNotificationSuccess() { function getStatusRuntime() { return { - worker: { ...state.worker }, + worker: { ...state.worker }, notifications: { ...state.notifications }, recentErrors: [...state.recentErrors], }; diff --git a/workers/dailyWorker.js b/workers/dailyWorker.js index 0f31331..b562aef 100644 --- a/workers/dailyWorker.js +++ b/workers/dailyWorker.js @@ -1,5 +1,7 @@ +'use strict'; + const cron = require('node-cron'); -const { getDb, getSetting } = require('../db/database'); +const { getDb } = require('../db/database'); const { buildTrackerRow, getCycleRange } = require('../services/statusService'); const { pruneExpiredSessions } = require('../services/authService'); const { runNotifications, runDriftNotifications } = require('../services/notificationService'); @@ -26,44 +28,77 @@ async function runDailyTasks() { const month = now.getMonth() + 1; const todayStr = now.toISOString().slice(0, 10); - const bills = db.prepare('SELECT * FROM bills WHERE active = 1').all(); + const bills = db.prepare('SELECT * FROM bills WHERE active = 1 AND deleted_at IS NULL').all(); - for (const bill of bills) { - // Use the bill's own cycle range so quarterly/annual bills look at the - // correct payment window — not just the calendar month. - const range = getCycleRange(year, month, bill); + if (bills.length > 0) { + // Batch-fetch payments for all active bills from the last 90 days to avoid + // one query per bill. Different billing cycles (monthly/quarterly/annual) all + // fit inside a 90-day window for the current month's due-date checks. + const billIds = bills.map(b => b.id); + const placeholders = billIds.map(() => '?').join(','); + const windowStart = new Date(Date.now() - 90 * 86400000).toISOString().slice(0, 10); - // Bill does not apply this month (quarterly/annual in a non-due month). - if (!range) continue; + let allPayments = []; + try { + allPayments = db.prepare(` + SELECT * FROM payments + WHERE bill_id IN (${placeholders}) + AND paid_date >= ? + AND deleted_at IS NULL + `).all(...billIds, windowStart); + } catch (err) { + console.error('[worker] Failed to batch-fetch payments:', err.message); + } - const payments = db.prepare( - 'SELECT * FROM payments WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL' - ).all(bill.id, range.start, range.end); + // Group payments by bill_id in memory + const paymentsByBill = new Map(); + for (const p of allPayments) { + if (!paymentsByBill.has(p.bill_id)) paymentsByBill.set(p.bill_id, []); + paymentsByBill.get(p.bill_id).push(p); + } - const row = buildTrackerRow(bill, payments, year, month, todayStr); + // Prepare autopay update statement once outside the loop + const markAutopay = db.prepare( + "UPDATE bills SET autodraft_status = 'assumed_paid', updated_at = datetime('now') WHERE id = ?" + ); - // row is null when the bill's cycle produces no due date this month. - // Guard defensively in case getCycleRange and resolveDueDate ever disagree. - if (!row) continue; + for (const bill of bills) { + const range = getCycleRange(year, month, bill); + if (!range) continue; // bill does not apply this cycle - // Auto-mark autopay bills as assumed_paid on due date - if ( - bill.autopay_enabled && - bill.autodraft_status === 'pending' && - todayStr >= row.due_date - ) { - db.prepare("UPDATE bills SET autodraft_status = 'assumed_paid', updated_at = datetime('now') WHERE id = ?") - .run(bill.id); + // Filter pre-fetched payments to the bill's cycle range + const payments = (paymentsByBill.get(bill.id) || []).filter( + p => p.paid_date >= range.start && p.paid_date <= range.end + ); + + const row = buildTrackerRow(bill, payments, year, month, todayStr); + if (!row) continue; + + // Auto-mark autopay bills as assumed_paid on due date + if ( + bill.autopay_enabled && + bill.autodraft_status === 'pending' && + todayStr >= row.due_date + ) { + try { + markAutopay.run(bill.id); + } catch (err) { + console.error(`[worker] Failed to mark autopay for bill ${bill.id}:`, err.message); + } + } } } pruneExpiredSessions(); - await runNotifications(); + + await runNotifications().catch(err => { + console.error('[worker] Notification error (non-fatal):', err.message); + }); + await runDriftNotifications().catch(err => { console.error('[worker] Drift notification error (non-fatal):', err.message); }); - // Run scheduled cleanup tasks (expired import sessions, stale temp files, etc.) await runAllCleanup().catch(err => { console.error('[worker] Cleanup error (non-fatal):', err.message); });