Search cards for "health" departments show inconsistent spend data between requests - sometimes displaying total amounts and sample records, sometimes not. No obvious Supabase errors are logged.
After examining the search page code, I found the primary cause of the inconsistency:
// In search/page.tsx - this triggers for EVERY search result
useEffect(() => {
await Promise.all(
searchData.departments.map(async (dept) => {
const [vendorRes, budgetRes] = await Promise.all([
fetch(`/api/spend?department_code=${dept.id}&limit=1`),
fetch(`/api/spend?view=budget&department_code=${dept.id}&limit=1`),
]);
})
);
}, [searchData]);Impact: A search for "health" returning 20 departments triggers 40 concurrent API calls to /api/spend, which:
- Has NO Redis caching (unlike other APIs)
- Uses complex year-partitioned view logic
- Queries up to 9 database views per request when no year specified
- Has silent error handling that masks partial failures
This creates a perfect storm of database load and timing inconsistencies.
The spend API has complex branching logic based on year parameters:
// Different code paths based on year parameter
if (year && yearPartitionedViewExists(parseInt(year))) {
// Use specific year view: vendor_transactions_with_vendor_fy{year}
const viewName = getYearPartitionedViewName(parseInt(year));
} else {
// Query ALL years separately and combine
const years = [2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024];
for (const yearValue of years) {
// Multiple database queries with potential for partial failures
}
}Potential Issues:
- When no year is specified, the API queries 9 different year-partitioned views
- If ANY year view fails or times out, it continues with warnings but may return incomplete data
- Error handling uses
console.warn()andcontinue, masking partial failures
const { data: yearData, error } = await yearQuery;
if (error) {
console.warn(`Error querying ${viewName}:`, error);
continue; // ⚠️ Silently skips failed years
}This means a search for "health" could return different result sets depending on which year-partitioned views succeed or fail.
// Two separate API calls with potential timing issues
let vendorUrl = `/api/spend?department_code=${departmentCode}&limit=10`;
let budgetUrl = `/api/spend?view=budget&department_code=${departmentCode}&limit=10`;
const vendorResponse = await fetch(vendorUrl);
const budgetResponse = await fetch(budgetUrl);Potential Issues:
- No synchronization between vendor and budget API calls
- Different response times could create inconsistent UI states
- Error in one call doesn't affect the other, leading to partial data display
// Search returns departments with IDs, but spend API uses department_code
const departmentCode = item.id; // This may not always be the correct department_codePotential Issues:
- Search index
item.idmay not match the spend data'sdepartment_code - Department names vs. codes inconsistency across different data sources
The application relies on several materialized views:
vendor_transactions_with_vendor_fy{year}(9 separate views)budget_line_items_with_namesdepartment_compare_summarydepartments_with_workforce
Potential Issues:
- Views may not be refreshed consistently
- Some views might be missing or corrupted
- Data inconsistency between views if they're updated at different times
Unlike other APIs that use Redis caching, the /api/spend route does NOT implement Redis caching:
// Other APIs use caching:
const cached = await getFromCache(cacheKey);
if (cached) return cached;
// But /api/spend does NOT - always hits databasePotential Issues:
- High database load leading to timeouts
- No cache consistency to fall back on
- Performance issues under load
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200'
}Browser/CDN caching might serve stale data inconsistently.
const spending = data?.map(item => ({
year: item.fiscal_year,
department: item.department_name || 'Unknown Department',
departmentCode: item.department_code || null,
vendor: item.vendor_name || 'Unknown Vendor',
// ... multiple transformations
})) || [];Potential Issues:
- Null/undefined handling inconsistencies
- Type coercion issues with amount parsing
- Missing data handling varies across different code paths
useEffect(() => {
const fetchMatchData = async () => {
// Complex async logic with multiple API calls
// No proper cleanup or cancellation
};
fetchMatchData();
}, [item.term, item.type, query]); // Multiple changing dependenciesPotential Issues:
- Multiple useEffect triggers could create overlapping async operations
- No request cancellation when component updates rapidly
- State updates from old requests overwriting newer ones
// In src/app/api/spend/route.ts
import { getFromCache, setInCache } from '@/lib/cache';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const cacheKey = `spend:${searchParams.toString()}`;
// Try cache first
const cached = await getFromCache(cacheKey);
if (cached) {
return NextResponse.json(cached, {
headers: { 'X-Cache': 'HIT' }
});
}
// ... existing logic ...
// Cache for 1 hour
await setInCache(cacheKey, result, { ex: 3600, tags: ['spend'] });
return NextResponse.json(result);
}Create /api/spend/batch-totals to get all department totals in one request:
// New endpoint: /api/spend/batch-totals
export async function POST(request: NextRequest) {
const { departmentCodes } = await request.json();
const cacheKey = `spend:batch:${departmentCodes.sort().join(',')}`;
const cached = await getFromCache(cacheKey);
if (cached) return NextResponse.json(cached);
// Single query for all departments
const results = await Promise.allSettled(
departmentCodes.map(async (code) => {
// Get totals for each department
})
);
await setInCache(cacheKey, results, { ex: 3600 });
return NextResponse.json(results);
}// Replace individual calls with batch call
const fetchTotals = async () => {
const departmentCodes = searchData.departments.map(d => d.id);
const response = await fetch('/api/spend/batch-totals', {
method: 'POST',
body: JSON.stringify({ departmentCodes })
});
const totals = await response.json();
setDepartmentTotals(totals);
};Add request tracking to identify patterns:
// In /api/spend route
console.log(`[SPEND API] Request: ${request.url} at ${new Date().toISOString()}`);
console.log(`[SPEND API] Params:`, { department, departmentCode, year, view });Instead of searching "health", test specific department codes:
# Test these specific calls that SearchDetailCards makes:
curl "http://localhost:3000/api/search?q=health&types=departments&limit=5"
# Then for each department ID returned:
curl "http://localhost:3000/api/spend?department_code={ID}&limit=10"
curl "http://localhost:3000/api/spend?view=budget&department_code={ID}&limit=10"Verify all year-partitioned views exist and have data:
SELECT schemaname, viewname, viewowner FROM pg_views
WHERE viewname LIKE 'vendor_transactions_with_vendor_fy%';- 40+ concurrent requests to uncached
/api/spendendpoint - Database overwhelmed with year-partitioned view queries
- Race conditions in response timing
- Some year views fail silently
- Data completeness varies per request
- No proper error aggregation
- Search returns one ID format
- Spend API expects different department_code format
- Results in no data found for some departments
- No Redis caching means every request hits database
- Heavy queries across 9 year views
- Supabase rate limits or connection pooling issues
- Multiple useEffect triggers
- Async state updates from overlapping requests
- No request cancellation
- Add Redis caching to
/api/spend⚡ - Add comprehensive logging to track request patterns
- Add request IDs to correlate frontend and backend logs
- Create batch totals API to reduce request volume
- Implement proper error aggregation instead of silent continues
- Add request cancellation in React components
- Standardize department ID/code mapping
- Consolidate year-partitioned views into a single performant view
- Implement database connection pooling and timeout handling
- Add automated view refresh monitoring
- Monitor request volume: Check how many
/api/spendcalls happen per search - Test with caching: Implement Redis caching and measure improvement
- Load testing: Use concurrent requests to expose timing issues
- Reproduce with specific departments: Target specific department codes that show inconsistency
This analysis confirms the issue is primarily architectural - too many uncached database requests overwhelming the system, rather than a pure cache or timeout issue.