ObsiViewer/server/index-phase3-patch.mjs

292 lines
12 KiB
JavaScript

/**
* Phase 3 Patch - Endpoint modifications for caching and monitoring
*
* This file contains the updated endpoints that should replace the old ones in index.mjs
* Apply these changes step by step:
*
* 1. Replace /api/vault/metadata endpoint (lines ~500-551)
* 2. Replace /api/vault/metadata/paginated endpoint (lines ~553-620)
* 3. Add /__perf endpoint for monitoring (new)
* 4. Add startup hook for deferred Meilisearch indexing (new)
*/
// ============================================================================
// ENDPOINT 1: /api/vault/metadata - with cache read-through and monitoring
// ============================================================================
export function setupMetadataEndpoint(app, metadataCache, performanceMonitor, vaultDir, meilisearchCircuitBreaker, retryWithBackoff, { meiliClient, vaultIndexName, ensureIndexSettings, loadVaultMetadataOnly }) {
app.get('/api/vault/metadata', async (req, res) => {
const startTime = performanceMonitor.markRequestStart();
try {
// Use cache.remember() for read-through caching
const { value: metadata, hit } = await metadataCache.remember(
`metadata:${vaultDir}`,
async () => {
// Try Meilisearch first with circuit breaker
try {
return await meilisearchCircuitBreaker.execute(
async () => {
const client = meiliClient();
const indexUid = vaultIndexName(vaultDir);
const index = await ensureIndexSettings(client, indexUid);
const result = await index.search('', {
limit: 10000,
attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt']
});
const items = Array.isArray(result.hits) ? result.hits.map(hit => ({
id: hit.id,
title: hit.title,
filePath: hit.path,
createdAt: typeof hit.createdAt === 'number' ? new Date(hit.createdAt).toISOString() : hit.createdAt,
updatedAt: typeof hit.updatedAt === 'number' ? new Date(hit.updatedAt).toISOString() : hit.updatedAt,
})) : [];
console.log(`[/api/vault/metadata] Loaded ${items.length} items from Meilisearch`);
return items;
},
{
onRetry: ({ attempt, delay, err }) => {
console.warn(`[Meilisearch] Retry attempt ${attempt}, delay ${delay}ms:`, err.message);
performanceMonitor.markRetry('meilisearch');
},
onCircuitOpen: ({ failureCount }) => {
console.error(`[Meilisearch] Circuit breaker opened after ${failureCount} failures`);
}
}
);
} catch (meiliError) {
console.warn('[Meilisearch] Failed, falling back to filesystem:', meiliError.message);
// Fallback to filesystem with retry
return await retryWithBackoff(
async () => {
const notes = await loadVaultMetadataOnly(vaultDir);
const metadata = notes.map(n => ({
id: n.id,
title: n.title,
filePath: n.filePath,
createdAt: n.createdAt,
updatedAt: n.updatedAt
}));
console.log(`[/api/vault/metadata] Loaded ${metadata.length} items from filesystem`);
return metadata;
},
{
retries: 2,
baseDelayMs: 100,
maxDelayMs: 500,
onRetry: ({ attempt, delay, err }) => {
console.warn(`[Filesystem] Retry attempt ${attempt}, delay ${delay}ms:`, err.message);
performanceMonitor.markRetry('filesystem');
}
}
);
}
}
);
performanceMonitor.markCache(hit);
const duration = performanceMonitor.markRequestEnd(startTime, true);
console.log(`[/api/vault/metadata] ${hit ? 'CACHE HIT' : 'CACHE MISS'} - ${duration}ms`);
res.json({
items: metadata,
cached: hit,
duration
});
} catch (error) {
performanceMonitor.markRequestEnd(startTime, false);
console.error('[/api/vault/metadata] Error:', error);
res.status(500).json({ error: 'Unable to load vault metadata.' });
}
});
}
// ============================================================================
// ENDPOINT 2: /api/vault/metadata/paginated - with cache and monitoring
// ============================================================================
export function setupPaginatedMetadataEndpoint(app, metadataCache, performanceMonitor, vaultDir, meilisearchCircuitBreaker, retryWithBackoff, { meiliClient, vaultIndexName, ensureIndexSettings, loadVaultMetadataOnly }) {
app.get('/api/vault/metadata/paginated', async (req, res) => {
const startTime = performanceMonitor.markRequestStart();
try {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const cursor = parseInt(req.query.cursor) || 0;
const search = req.query.search || '';
const cacheKey = `paginated:${vaultDir}:${search}`;
// For paginated requests, we cache the full result set and paginate client-side
const { value: allMetadata, hit } = await metadataCache.remember(
cacheKey,
async () => {
try {
return await meilisearchCircuitBreaker.execute(
async () => {
const client = meiliClient();
const indexUid = vaultIndexName(vaultDir);
const index = await ensureIndexSettings(client, indexUid);
const result = await index.search(search, {
limit: 10000,
attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt'],
sort: ['updatedAt:desc']
});
return Array.isArray(result.hits) ? result.hits.map(hit => ({
id: hit.id,
title: hit.title,
filePath: hit.path,
createdAt: typeof hit.createdAt === 'number' ? new Date(hit.createdAt).toISOString() : hit.createdAt,
updatedAt: typeof hit.updatedAt === 'number' ? new Date(hit.updatedAt).toISOString() : hit.updatedAt,
})) : [];
},
{
onRetry: ({ attempt, delay, err }) => {
console.warn(`[Meilisearch] Paginated retry ${attempt}, delay ${delay}ms:`, err.message);
performanceMonitor.markRetry('meilisearch');
}
}
);
} catch (meiliError) {
console.warn('[Meilisearch] Paginated failed, falling back to filesystem:', meiliError.message);
return await retryWithBackoff(
async () => {
const allMetadata = await loadVaultMetadataOnly(vaultDir);
let filtered = allMetadata;
if (search) {
const searchLower = search.toLowerCase();
filtered = allMetadata.filter(item =>
(item.title || '').toLowerCase().includes(searchLower) ||
(item.filePath || '').toLowerCase().includes(searchLower)
);
}
filtered.sort((a, b) => {
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
return dateB - dateA;
});
return filtered.map(n => ({
id: n.id,
title: n.title,
filePath: n.filePath,
createdAt: n.createdAt,
updatedAt: n.updatedAt
}));
},
{
retries: 2,
baseDelayMs: 100,
maxDelayMs: 500,
onRetry: ({ attempt, delay, err }) => {
console.warn(`[Filesystem] Paginated retry ${attempt}, delay ${delay}ms:`, err.message);
performanceMonitor.markRetry('filesystem');
}
}
);
}
}
);
// Paginate the cached result
const paginatedItems = allMetadata.slice(cursor, cursor + limit);
const hasMore = cursor + limit < allMetadata.length;
const nextCursor = hasMore ? cursor + limit : null;
performanceMonitor.markCache(hit);
const duration = performanceMonitor.markRequestEnd(startTime, true);
console.log(`[/api/vault/metadata/paginated] ${hit ? 'CACHE HIT' : 'CACHE MISS'} - cursor=${cursor}, limit=${limit}, duration=${duration}ms`);
res.json({
items: paginatedItems,
nextCursor,
hasMore,
total: allMetadata.length,
cached: hit,
duration
});
} catch (error) {
performanceMonitor.markRequestEnd(startTime, false);
console.error('[/api/vault/metadata/paginated] Error:', error);
res.status(500).json({ error: 'Pagination failed' });
}
});
}
// ============================================================================
// ENDPOINT 3: /__perf - Performance monitoring dashboard
// ============================================================================
export function setupPerformanceEndpoint(app, performanceMonitor, metadataCache, meilisearchCircuitBreaker) {
app.get('/__perf', (req, res) => {
res.json({
performance: performanceMonitor.snapshot(),
cache: metadataCache.getStats(),
circuitBreaker: meilisearchCircuitBreaker.getState(),
timestamp: new Date().toISOString()
});
});
}
// ============================================================================
// STARTUP HOOK: Deferred Meilisearch indexing (non-blocking)
// ============================================================================
export async function setupDeferredIndexing(vaultDir, fullReindex) {
let indexingInProgress = false;
let indexingCompleted = false;
let lastIndexingAttempt = 0;
const INDEXING_COOLDOWN = 5 * 60 * 1000; // 5 minutes
async function scheduleIndexing() {
const now = Date.now();
if (indexingInProgress || (now - lastIndexingAttempt) < INDEXING_COOLDOWN) {
return;
}
indexingInProgress = true;
lastIndexingAttempt = now;
console.log('[Meilisearch] Scheduling background indexing...');
// Use setImmediate to not block startup
setImmediate(async () => {
try {
console.time('[Meilisearch] Background indexing');
await fullReindex(vaultDir);
console.timeEnd('[Meilisearch] Background indexing');
console.log('[Meilisearch] Background indexing completed successfully');
indexingCompleted = true;
} catch (error) {
console.error('[Meilisearch] Background indexing failed:', error.message);
indexingCompleted = false;
// Schedule retry in 5 minutes
setTimeout(() => {
console.log('[Meilisearch] Retrying indexing in 5 minutes...');
indexingInProgress = false;
scheduleIndexing();
}, INDEXING_COOLDOWN);
} finally {
indexingInProgress = false;
}
});
}
return {
scheduleIndexing,
getState: () => ({ indexingInProgress, indexingCompleted, lastIndexingAttempt })
};
}