perf: worker N+1 batching, status runtime DB persistence, 'use strict'

This commit is contained in:
null 2026-06-04 21:32:28 -05:00
parent 81ae41325a
commit a2266635f4
2 changed files with 119 additions and 46 deletions

View File

@ -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],
};

View File

@ -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);
});