perf(tracker): batch getTracker per-bill queries (kill N+1) (P1)

getTracker ran a payments query per bill plus computeAmountSuggestion per bill
(up to 12 queries each: 6 months x 2) inside bills.map — ~70-450 queries for a
35-bill account per load. Now one query fetches all cycle payments (grouped in
JS per bill range) and two compute all amount suggestions
(computeAmountSuggestionsBatch). Behavior-preserving.

Test: tests/amountSuggestionService.test.js pins batch == per-bill output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 18:25:55 -05:00
parent 73631ab812
commit 9f4b53d37a
4 changed files with 225 additions and 23 deletions

View File

@ -1,6 +1,10 @@
# Bill Tracker — Changelog
## v0.41.0
### ⚡ Tracker performance
- **[Tracker] Killed the getTracker N+1 (was ~23 DB round-trips × N bills every home-page load)** — inside `bills.map`, `getTracker` ran a payments query per bill (`fetchPaymentsForBillCycle`) plus `computeAmountSuggestion` per bill, and the suggestion alone fired up to 12 queries per bill (6 months × 2) — roughly 70450 queries for a 35-bill account on every Tracker load. Now one query fetches all bills' cycle payments (grouped in JS by each bill's own range) and two queries compute all amount suggestions (`computeAmountSuggestionsBatch`), replacing the per-bill loops. Behavior-preserving — `tests/amountSuggestionService.test.js` pins the batched suggestion to be byte-identical to the per-bill function, and the `trackerService` tests still pass unchanged. (Tracker P1)
### 🐛 Tracker & bill-modal hardening
- **[Tracker] Route error handling + autopay write atomicity** — `routes/tracker.js` had no try/catch and returned a plain `{error}` shape unlike the rest of the API; its three GET handlers now wrap in try/catch and return `standardizeError` (`{error, message, field, code}`), including the invalid-month validation path. Separately, `applyAutopaySuggestions` (which runs on a `GET /tracker`) inserted the auto-mark-paid payment and applied the balance delta as two un-transactional steps — a mid-way failure could leave a payment without its balance adjustment. Wrapped the INSERT + `applyBalanceDelta` in a single `db.transaction`. Covered by new cases in `tests/trackerService.test.js` (auto-mark creates one payment + drops the balance, idempotent on re-load; route returns the standardized error). (Tracker T2)

View File

@ -3,6 +3,35 @@
const { accountingActiveSql } = require('./paymentAccountingService');
const { fromCents } = require('../utils/money');
// The 6 calendar months immediately preceding (year, month), newest first, each
// as { y, m, key: 'YYYY-MM' }.
function priorMonths(year, month, count = 6) {
const list = [];
let y = year;
let m = month;
for (let i = 0; i < count; i++) {
m -= 1;
if (m === 0) { m = 12; y -= 1; }
list.push({ y, m, key: `${y}-${String(m).padStart(2, '0')}` });
}
return list;
}
// Rolling median of a bill's monthly amounts → a suggestion object (or null).
function medianSuggestion(amounts) {
if (amounts.length === 0) return null;
const sorted = [...amounts].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
return {
suggestion: fromCents(Math.round(median)),
months_used: amounts.length,
confidence: amounts.length >= 3 ? 'high' : 'low',
};
}
/**
* Computes a suggested expected amount for a bill based on the rolling median
* of the last 6 months of actual data. Prefers monthly_bill_state.actual_amount
@ -10,13 +39,8 @@ const { fromCents } = require('../utils/money');
*/
function computeAmountSuggestion(db, billId, year, month) {
const amounts = [];
let y = year;
let m = month;
for (let i = 0; i < 6; i++) {
m -= 1;
if (m === 0) { m = 12; y -= 1; }
for (const { y, m } of priorMonths(year, month, 6)) {
const mbs = db.prepare(
'SELECT actual_amount FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?'
).get(billId, y, m);
@ -39,19 +63,75 @@ function computeAmountSuggestion(db, billId, year, month) {
if (result.total > 0) amounts.push(result.total);
}
if (amounts.length === 0) return null;
const sorted = [...amounts].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
return {
suggestion: fromCents(Math.round(median)),
months_used: amounts.length,
confidence: amounts.length >= 3 ? 'high' : 'low',
};
return medianSuggestion(amounts);
}
module.exports = { computeAmountSuggestion };
/**
* Batched form of computeAmountSuggestion for many bills at once: two queries
* total (monthly_bill_state + grouped payment sums) instead of up to 12 per
* bill. Returns a Map<billId, suggestion|null>. Behavior-identical to calling
* computeAmountSuggestion per bill (guarded by amountSuggestionService.test.js).
*/
function computeAmountSuggestionsBatch(db, billIds, year, month) {
const result = new Map();
if (!billIds || billIds.length === 0) return result;
const months = priorMonths(year, month, 6);
const monthKeys = new Set(months.map(mm => mm.key));
const minKey = months[months.length - 1].key; // earliest (month 6)
const maxKey = months[0].key; // latest (month 1)
// Broad window that fully spans the 6 months; the monthKeys filter below is
// the exact gate, so a '-01'…'-31' string window is safe for ISO dates.
const windowStart = `${minKey}-01`;
const windowEnd = `${maxKey}-31`;
const placeholders = billIds.map(() => '?').join(',');
// User-corrected monthly amounts.
const mbsMap = new Map(); // billId → { 'YYYY-MM': actual_amount }
const mbsRows = db.prepare(`
SELECT bill_id, year, month, actual_amount
FROM monthly_bill_state
WHERE bill_id IN (${placeholders})
`).all(...billIds);
for (const r of mbsRows) {
if (r.actual_amount == null) continue;
const key = `${r.year}-${String(r.month).padStart(2, '0')}`;
if (!monthKeys.has(key)) continue;
if (!mbsMap.has(r.bill_id)) mbsMap.set(r.bill_id, {});
mbsMap.get(r.bill_id)[key] = r.actual_amount;
}
// Payment sums grouped by bill + month.
const payMap = new Map(); // billId → { 'YYYY-MM': total }
const payRows = db.prepare(`
SELECT bill_id, strftime('%Y-%m', paid_date) AS ym, COALESCE(SUM(amount), 0) AS total
FROM payments
WHERE bill_id IN (${placeholders})
AND deleted_at IS NULL
AND ${accountingActiveSql()}
AND paid_date BETWEEN ? AND ?
GROUP BY bill_id, ym
`).all(...billIds, windowStart, windowEnd);
for (const r of payRows) {
if (!monthKeys.has(r.ym)) continue;
if (!payMap.has(r.bill_id)) payMap.set(r.bill_id, {});
payMap.get(r.bill_id)[r.ym] = r.total;
}
for (const billId of billIds) {
const amounts = [];
const mbsFor = mbsMap.get(billId) || {};
const payFor = payMap.get(billId) || {};
for (const { key } of months) {
if (mbsFor[key] != null) { amounts.push(mbsFor[key]); continue; }
const total = payFor[key] || 0;
if (total > 0) amounts.push(total);
}
result.set(billId, medianSuggestion(amounts));
}
return result;
}
module.exports = { computeAmountSuggestion, computeAmountSuggestionsBatch };

View File

@ -4,7 +4,7 @@ const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeAmountSuggestion } = require('./amountSuggestionService');
const { computeAmountSuggestionsBatch } = require('./amountSuggestionService');
const { accountingActiveSql } = require('./paymentAccountingService');
const { normalizeMerchant } = require('./subscriptionService');
const { localDateString } = require('../utils/dates');
@ -223,6 +223,46 @@ function fetchPaymentsForBillCycle(db, bill, year, month) {
`).all(bill.id, range.start, range.end);
}
// Batched form of fetchPaymentsForBillCycle: one query for every bill's cycle
// payments (grouped in JS by each bill's own range) instead of one query per
// bill. Returns Map<billId, payments[]> with each list still ordered paid_date
// DESC — identical to calling fetchPaymentsForBillCycle per bill.
function fetchPaymentsForBillsInMonth(db, bills, year, month) {
const ranges = new Map(); // billId → { start, end } (only bills due this month)
let minStart = null;
let maxEnd = null;
for (const bill of bills) {
const range = getCycleRange(year, month, bill);
if (!range) continue;
ranges.set(bill.id, range);
if (minStart === null || range.start < minStart) minStart = range.start;
if (maxEnd === null || range.end > maxEnd) maxEnd = range.end;
}
const byBill = new Map();
if (minStart === null) return byBill;
const ids = [...ranges.keys()];
const placeholders = ids.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, id, amount, paid_date, method, notes, payment_source, transaction_id, created_at, updated_at
FROM payments
WHERE bill_id IN (${placeholders}) AND paid_date BETWEEN ? AND ?
AND deleted_at IS NULL
AND ${accountingActiveSql()}
ORDER BY paid_date DESC
`).all(...ids, minStart, maxEnd);
for (const row of rows) {
const range = ranges.get(row.bill_id);
// The union window may be wider than this bill's own cycle — filter to it.
if (row.paid_date < range.start || row.paid_date > range.end) continue;
if (!byBill.has(row.bill_id)) byBill.set(row.bill_id, []);
byBill.get(row.bill_id).push(row);
}
return byBill;
}
function fetchPreviousMonthPaid(db, billIds, range) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
@ -519,13 +559,17 @@ function getTracker(userId, query = {}, now = new Date()) {
const dismissedSuggestions = fetchDismissedSuggestions(db, userId, billIds, year, month);
const sparklines = fetchSparklines(db, billIds);
const autopayStatsMap = fetchAutopayStats(db, billIds);
// Batched to avoid an N+1 across bills (was ~23 queries × N bills every load):
// one query for all cycle payments, two for all amount suggestions.
const paymentsByBill = fetchPaymentsForBillsInMonth(db, bills, year, month);
const amountSuggestions = computeAmountSuggestionsBatch(db, billIds, year, month);
const rows = bills.map(bill => {
bill.sparkline = sparklines[bill.id] ?? null;
bill.autopay_stats = autopayStatsMap[bill.id] ?? null;
if (!resolveDueDate(bill, year, month)) return null;
const payments = fetchPaymentsForBillCycle(db, bill, year, month);
const payments = paymentsByBill.get(bill.id) || [];
const mbs = monthlyStates[bill.id];
const autopaySuggestion = applyAutopaySuggestions(
db,
@ -551,7 +595,7 @@ function getTracker(userId, query = {}, now = new Date()) {
row.snoozed_until = mbs?.snoozed_until ?? null;
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = fromCents(prevMonthPayments[bill.id] || 0);
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
row.amount_suggestion = amountSuggestions.get(bill.id) ?? null;
return row;
}).filter(Boolean);

View File

@ -0,0 +1,74 @@
'use strict';
// P1 — computeAmountSuggestionsBatch replaces up to 12 per-bill queries with two
// batched queries in getTracker. This pins that the batched form is byte-identical
// to calling the single-bill computeAmountSuggestion per bill (the behavior we
// rely on for the N+1 fix to be safe).
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-amountsug-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const { computeAmountSuggestion, computeAmountSuggestionsBatch } = require('../services/amountSuggestionService');
let db, userId, bills;
test.before(() => {
db = getDb();
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('as-user','x','user',1)").run().lastInsertRowid;
const insBill = db.prepare("INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, ?, 1, 10000, 1)");
bills = {
payments: insBill.run(userId, 'Payments only').lastInsertRowid, // suggestion from payment sums
mbs: insBill.run(userId, 'Corrected').lastInsertRowid, // user-corrected actual_amount wins
mixed: insBill.run(userId, 'Mixed').lastInsertRowid, // some months mbs, some payments
empty: insBill.run(userId, 'No history').lastInsertRowid, // → null suggestion
};
const pay = db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')");
// For query month = 2026-06: prior months are May, Apr, Mar, Feb, Jan 2026, Dec 2025.
pay.run(bills.payments, 10000, '2026-05-15');
pay.run(bills.payments, 12000, '2026-04-15');
pay.run(bills.payments, 11000, '2026-03-15');
// Two payments in one month should sum.
pay.run(bills.mixed, 3000, '2026-05-10');
pay.run(bills.mixed, 2000, '2026-05-20'); // May total 5000
pay.run(bills.mixed, 9000, '2026-04-10');
const mbs = db.prepare("INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount) VALUES (?, ?, ?, ?)");
mbs.run(bills.mbs, 2026, 5, 8000);
mbs.run(bills.mbs, 2026, 4, 8500);
mbs.run(bills.mbs, 2026, 3, 7500);
// Mixed: March has a corrected amount that should win over any payment sum.
mbs.run(bills.mixed, 2026, 3, 4200);
});
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
});
test('batch output equals per-bill computeAmountSuggestion for every bill', () => {
const ids = Object.values(bills);
const batch = computeAmountSuggestionsBatch(db, ids, 2026, 6);
for (const id of ids) {
const single = computeAmountSuggestion(db, id, 2026, 6);
assert.deepEqual(batch.get(id), single, `bill ${id} batch matches single`);
}
});
test('empty bill list returns an empty map; no-history bill → null', () => {
assert.equal(computeAmountSuggestionsBatch(db, [], 2026, 6).size, 0);
const batch = computeAmountSuggestionsBatch(db, [bills.empty], 2026, 6);
assert.equal(batch.get(bills.empty), null);
});
test('corrected monthly amount wins over payment sums (median of mbs values)', () => {
const s = computeAmountSuggestion(db, bills.mbs, 2026, 6);
// median of [8000, 8500, 7500] = 8000 → $80
assert.equal(s.suggestion, 80);
assert.equal(s.months_used, 3);
assert.equal(s.confidence, 'high');
});