From 09cd410eaa6358b74eb26f09d4066029587882b8 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Mon, 6 Apr 2026 12:42:45 +0200 Subject: [PATCH] perf: optimize DB connections, queries, and indexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix dev-mode reconnect storm by persisting mongoose connection state on globalThis instead of a module-level flag that resets on Vite HMR. Eliminate redundant in_season DB query on /rezepte — derive seasonal subset from all_brief client-side. Parallelize all page load fetches. Replace N+1 settlement queries in balance route with single batch $in query. Parallelize balance sum and recent splits aggregations. Trim unused dateModified/dateCreated from recipe brief projections. Add indexes: Payment(date, createdAt), PaymentSplit(username), Recipe(short_name), Recipe(season). --- src/lib/server/recipeHelpers.ts | 6 +- src/models/Payment.ts | 2 + src/models/PaymentSplit.ts | 1 + src/models/Recipe.ts | 2 + .../[recipeLang=recipeLang]/+page.server.ts | 20 +++--- src/routes/api/cospend/balance/+server.ts | 67 +++++++++++-------- src/utils/db.ts | 47 +++++-------- 7 files changed, 74 insertions(+), 71 deletions(-) diff --git a/src/lib/server/recipeHelpers.ts b/src/lib/server/recipeHelpers.ts index 315b95d..d34ad36 100644 --- a/src/lib/server/recipeHelpers.ts +++ b/src/lib/server/recipeHelpers.ts @@ -20,8 +20,8 @@ export function briefQueryConfig(recipeLang: string) { prefix: en ? 'translations.en.' : '', /** Projection for brief list queries */ projection: en - ? '_id translations.en short_name images season dateModified icon' - : 'name short_name images tags category icon description season dateModified', + ? '_id translations.en short_name images season icon' + : 'name short_name images tags category icon description season', }; } @@ -45,8 +45,6 @@ export function toBrief(recipe: RecipeModelType, recipeLang: string): BriefRecip icon: recipe.icon, description: en?.description, season: recipe.season || [], - dateCreated: recipe.dateCreated, - dateModified: recipe.dateModified, germanShortName: recipe.short_name, } as unknown as BriefRecipeType; } diff --git a/src/models/Payment.ts b/src/models/Payment.ts index 5a14b6c..fd6ea93 100644 --- a/src/models/Payment.ts +++ b/src/models/Payment.ts @@ -89,6 +89,8 @@ const PaymentSchema = new mongoose.Schema( } ); +PaymentSchema.index({ date: -1, createdAt: -1 }); + PaymentSchema.virtual('splits', { ref: 'PaymentSplit', localField: '_id', diff --git a/src/models/PaymentSplit.ts b/src/models/PaymentSplit.ts index 221ba91..9ea3029 100644 --- a/src/models/PaymentSplit.ts +++ b/src/models/PaymentSplit.ts @@ -52,5 +52,6 @@ const PaymentSplitSchema = new mongoose.Schema( ); PaymentSplitSchema.index({ paymentId: 1, username: 1 }, { unique: true }); +PaymentSplitSchema.index({ username: 1 }); export const PaymentSplit = mongoose.model("PaymentSplit", PaymentSplitSchema); \ No newline at end of file diff --git a/src/models/Recipe.ts b/src/models/Recipe.ts index 42ba182..8d1a0ba 100644 --- a/src/models/Recipe.ts +++ b/src/models/Recipe.ts @@ -193,6 +193,8 @@ const RecipeSchema = new mongoose.Schema( ); // Indexes for efficient querying +RecipeSchema.index({ short_name: 1 }); +RecipeSchema.index({ season: 1 }); RecipeSchema.index({ "translations.en.short_name": 1 }); RecipeSchema.index({ "translations.en.translationStatus": 1 }); diff --git a/src/routes/[recipeLang=recipeLang]/+page.server.ts b/src/routes/[recipeLang=recipeLang]/+page.server.ts index b235c45..4214e58 100644 --- a/src/routes/[recipeLang=recipeLang]/+page.server.ts +++ b/src/routes/[recipeLang=recipeLang]/+page.server.ts @@ -3,22 +3,22 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori export const load: PageServerLoad = async ({ fetch, locals, params }) => { const apiBase = `/api/${params.recipeLang}`; + const currentMonth = new Date().getMonth() + 1; - let current_month = new Date().getMonth() + 1 - const res_season = await fetch(`${apiBase}/items/in_season/` + current_month); - const res_all_brief = await fetch(`${apiBase}/items/all_brief`); - const item_season = await res_season.json(); - const item_all_brief = await res_all_brief.json(); - - // Get user favorites and session - const [userFavorites, session] = await Promise.all([ + // Fetch all_brief, favorites, and session in parallel + const [res_all_brief, userFavorites, session] = await Promise.all([ + fetch(`${apiBase}/items/all_brief`).then(r => r.json()), getUserFavorites(fetch, locals), locals.auth() ]); + const all_brief = addFavoriteStatusToRecipes(res_all_brief, userFavorites); + // Derive seasonal subset from all_brief instead of a separate DB query + const season = all_brief.filter((r: any) => r.season?.includes(currentMonth) && r.icon !== '🍽️'); + return { - season: addFavoriteStatusToRecipes(item_season, userFavorites), - all_brief: addFavoriteStatusToRecipes(item_all_brief, userFavorites), + season, + all_brief, session, heroIndex: Math.random() }; diff --git a/src/routes/api/cospend/balance/+server.ts b/src/routes/api/cospend/balance/+server.ts index 4ba8740..f672cd7 100644 --- a/src/routes/api/cospend/balance/+server.ts +++ b/src/routes/api/cospend/balance/+server.ts @@ -52,37 +52,48 @@ export const GET: RequestHandler = async ({ locals, url }) => { return json(result); } else { - const userSplits = await PaymentSplit.find({ username }).lean(); - - // Calculate net balance: negative = you are owed money, positive = you owe money - const netBalance = userSplits.reduce((sum, split) => sum + split.amount, 0); - - const recentSplits = await PaymentSplit.aggregate([ - { $match: { username } }, - { - $lookup: { - from: 'payments', - localField: 'paymentId', - foreignField: '_id', - as: 'paymentId' - } - }, - { $unwind: '$paymentId' }, - { $sort: { 'paymentId.date': -1, 'paymentId.createdAt': -1 } }, - { $limit: 30 } + // Run balance sum and recent splits in parallel + const [balanceResult, recentSplits] = await Promise.all([ + PaymentSplit.aggregate([ + { $match: { username } }, + { $group: { _id: null, total: { $sum: '$amount' } } } + ]), + PaymentSplit.aggregate([ + { $match: { username } }, + { + $lookup: { + from: 'payments', + localField: 'paymentId', + foreignField: '_id', + as: 'paymentId' + } + }, + { $unwind: '$paymentId' }, + { $sort: { 'paymentId.date': -1, 'paymentId.createdAt': -1 } }, + { $limit: 30 } + ]) ]); - // For settlements, fetch the other user's split info - for (const split of recentSplits) { - if (split.paymentId && split.paymentId.category === 'settlement') { - // This is a settlement, find the other user - const otherSplit = await PaymentSplit.findOne({ - paymentId: split.paymentId._id, - username: { $ne: username } - }).lean(); + const netBalance = balanceResult[0]?.total ?? 0; - if (otherSplit) { - split.otherUser = otherSplit.username; + // Batch-fetch other users for settlements (avoids N+1 queries) + const settlementIds = recentSplits + .filter(s => s.paymentId?.category === 'settlement') + .map(s => s.paymentId._id); + + if (settlementIds.length > 0) { + const otherSplits = await PaymentSplit.find({ + paymentId: { $in: settlementIds } as any, + username: { $ne: username } + }).lean(); + + const otherUserByPayment = new Map( + otherSplits.map(s => [s.paymentId.toString(), s.username]) + ); + + for (const split of recentSplits) { + if (split.paymentId?.category === 'settlement') { + split.otherUser = otherUserByPayment.get(split.paymentId._id.toString()); } } } diff --git a/src/utils/db.ts b/src/utils/db.ts index 783d3ac..a85585d 100644 --- a/src/utils/db.ts +++ b/src/utils/db.ts @@ -1,47 +1,36 @@ import mongoose from 'mongoose'; import { MONGO_URL } from '$env/static/private'; -let isConnected = false; +// Use globalThis to persist connection promise across Vite HMR module reloads +const g = globalThis as unknown as { __mongoosePromise?: Promise }; export const dbConnect = async () => { - // If already connected, return immediately - if (isConnected && mongoose.connection.readyState === 1) { + // Already connected — return immediately + if (mongoose.connection.readyState === 1) { + return mongoose.connection; + } + + // Connection in progress — await the existing promise + if (mongoose.connection.readyState === 2 && g.__mongoosePromise) { + await g.__mongoosePromise; return mongoose.connection; } try { - // Configure MongoDB driver options const options = { - maxPoolSize: 10, // Maintain up to 10 socket connections - serverSelectionTimeoutMS: 5000, // Keep trying to send operations for 5 seconds - socketTimeoutMS: 45000, // Close sockets after 45 seconds of inactivity + maxPoolSize: 10, + serverSelectionTimeoutMS: 5000, + socketTimeoutMS: 45000, }; - const connection = await mongoose.connect(MONGO_URL ?? '', options); - - isConnected = true; + g.__mongoosePromise = mongoose.connect(MONGO_URL ?? '', options); + await g.__mongoosePromise; + console.log('MongoDB connected with persistent connection'); - - // Handle connection events - mongoose.connection.on('error', (err) => { - console.error('MongoDB connection error:', err); - isConnected = false; - }); - - mongoose.connection.on('disconnected', () => { - console.log('MongoDB disconnected'); - isConnected = false; - }); - - mongoose.connection.on('reconnected', () => { - console.log('MongoDB reconnected'); - isConnected = true; - }); - - return connection; + return mongoose.connection; } catch (error) { console.error('MongoDB connection failed:', error); - isConnected = false; + g.__mongoosePromise = undefined; throw error; } };