Files
arcum42-ComfyUI_SageUtils/js/shared/dataCache.js
T

605 lines
16 KiB
JavaScript

/**
* Global Data Cache System
*
* Centralized cache for preloaded data used across sidebar tabs.
* Provides status tracking, async loading, and cache invalidation.
*
* Usage:
* import { DataCache } from './shared/dataCache.js';
*
* // Check if data is ready
* if (DataCache.isReady('cacheHash')) {
* const data = DataCache.get('cacheHash');
* }
*
* // Wait for data to be ready
* const data = await DataCache.waitFor('galleryImages:notes');
*
* // Preload data
* await DataCache.preloadCache();
*/
// Cache status constants
export const CacheStatus = {
PENDING: 'pending',
LOADING: 'loading',
READY: 'ready',
ERROR: 'error',
STALE: 'stale'
};
// Cache key constants
export const CacheKeys = {
CACHE_HASH: 'cacheHash',
CACHE_INFO: 'cacheInfo',
GALLERY_NOTES: 'galleryImages:notes',
GALLERY_INPUT: 'galleryImages:input',
GALLERY_OUTPUT: 'galleryImages:output',
LLM_MODELS_OLLAMA: 'llmModels:ollama',
LLM_MODELS_LMSTUDIO: 'llmModels:lmstudio',
SETTINGS: 'settings'
};
/**
* Global data cache class
*/
class DataCacheClass {
constructor() {
// Cache storage
this.cache = new Map();
// Status tracking
this.status = new Map();
// Timestamps for cache invalidation
this.timestamps = new Map();
// Pending promises for in-flight requests
this.pending = new Map();
// Configuration
this.config = {
maxCacheSize: 50 * 1024 * 1024, // 50MB
defaultTTL: 5 * 60 * 1000, // 5 minutes
ttlByKey: {
[CacheKeys.CACHE_HASH]: 5 * 60 * 1000, // 5 minutes
[CacheKeys.CACHE_INFO]: 5 * 60 * 1000, // 5 minutes
[CacheKeys.SETTINGS]: 10 * 60 * 1000, // 10 minutes
// Gallery content can change while running; keep shorter TTLs
[CacheKeys.GALLERY_OUTPUT]: 30 * 1000, // 30 seconds for rapidly changing output folder
[CacheKeys.GALLERY_INPUT]: 60 * 1000, // 60 seconds for input folder
[CacheKeys.GALLERY_NOTES]: 2 * 60 * 1000 // 2 minutes for notes
}
};
// Initialize all keys as pending
Object.values(CacheKeys).forEach(key => {
this.status.set(key, CacheStatus.PENDING);
});
// Debug mode
this.debug = false;
// Metrics
this.metrics = {
hits: 0,
misses: 0,
loads: 0,
errors: 0
};
}
/**
* Enable/disable debug logging
* @param {boolean} enabled - Enable debug mode
*/
setDebug(enabled) {
this.debug = enabled;
if (this.debug) {
console.log('[DataCache] Debug mode enabled');
}
}
/**
* Get cached data
* @param {string} key - Cache key
* @returns {*} Cached data or null
*/
get(key) {
if (!this.cache.has(key)) {
this.metrics.misses++;
if (this.debug) {
console.log(`[DataCache] MISS: ${key}`);
}
return null;
}
// Check if stale
if (this.isStale(key)) {
if (this.debug) {
console.log(`[DataCache] STALE: ${key}`);
}
this.status.set(key, CacheStatus.STALE);
return this.cache.get(key); // Return stale data but mark as stale
}
this.metrics.hits++;
if (this.debug) {
console.log(`[DataCache] HIT: ${key}`);
}
return this.cache.get(key);
}
/**
* Set cached data
* @param {string} key - Cache key
* @param {*} value - Data to cache
*/
set(key, value) {
this.cache.set(key, value);
this.timestamps.set(key, Date.now());
this.status.set(key, CacheStatus.READY);
if (this.debug) {
console.log(`[DataCache] SET: ${key}`, value);
}
// Check cache size
this.enforceMaxSize();
}
/**
* Get cache status
* @param {string} key - Cache key
* @returns {string} Cache status
*/
getStatus(key) {
return this.status.get(key) || CacheStatus.PENDING;
}
/**
* Set cache status
* @param {string} key - Cache key
* @param {string} status - New status
*/
setStatus(key, status) {
this.status.set(key, status);
if (this.debug) {
console.log(`[DataCache] STATUS: ${key} -> ${status}`);
}
}
/**
* Check if data is ready
* @param {string} key - Cache key
* @returns {boolean} True if data is ready
*/
isReady(key) {
const status = this.getStatus(key);
return status === CacheStatus.READY || status === CacheStatus.STALE;
}
/**
* Check if data is stale
* @param {string} key - Cache key
* @returns {boolean} True if data is stale
*/
isStale(key) {
if (!this.timestamps.has(key)) {
return false;
}
const timestamp = this.timestamps.get(key);
const ttl = this.config.ttlByKey[key] || this.config.defaultTTL;
const age = Date.now() - timestamp;
return age > ttl;
}
/**
* Wait for data to be ready
* @param {string} key - Cache key
* @param {number} timeout - Timeout in milliseconds (default: 10000)
* @returns {Promise<*>} Promise that resolves with cached data
*/
async waitFor(key, timeout = 10000) {
// If already ready, return immediately
if (this.isReady(key)) {
return this.get(key);
}
// If already pending, return existing promise
if (this.pending.has(key)) {
if (this.debug) {
console.log(`[DataCache] Waiting for existing promise: ${key}`);
}
return this.pending.get(key);
}
// Create new promise
const promise = new Promise((resolve, reject) => {
const startTime = Date.now();
const checkStatus = () => {
const status = this.getStatus(key);
if (status === CacheStatus.READY || status === CacheStatus.STALE) {
this.pending.delete(key);
resolve(this.get(key));
} else if (status === CacheStatus.ERROR) {
this.pending.delete(key);
reject(new Error(`Failed to load: ${key}`));
} else if (Date.now() - startTime > timeout) {
this.pending.delete(key);
reject(new Error(`Timeout waiting for: ${key}`));
} else {
setTimeout(checkStatus, 50);
}
};
checkStatus();
});
this.pending.set(key, promise);
return promise;
}
/**
* Invalidate cached data
* @param {string} key - Cache key
*/
invalidate(key) {
this.cache.delete(key);
this.timestamps.delete(key);
this.status.set(key, CacheStatus.PENDING);
this.pending.delete(key);
if (this.debug) {
console.log(`[DataCache] INVALIDATE: ${key}`);
}
}
/**
* Clear all cached data
*/
clear() {
this.cache.clear();
this.timestamps.clear();
this.pending.clear();
// Reset all statuses to pending
Object.values(CacheKeys).forEach(key => {
this.status.set(key, CacheStatus.PENDING);
});
if (this.debug) {
console.log('[DataCache] CLEAR ALL');
}
}
/**
* Enforce maximum cache size by evicting oldest entries
*/
enforceMaxSize() {
let totalSize = 0;
const entries = Array.from(this.cache.entries());
// Calculate total size (rough estimate)
entries.forEach(([key, value]) => {
const size = JSON.stringify(value).length;
totalSize += size;
});
if (totalSize > this.config.maxCacheSize) {
if (this.debug) {
console.log(`[DataCache] Cache size (${totalSize}) exceeds max (${this.config.maxCacheSize}), evicting oldest entries`);
}
// Sort by timestamp (oldest first)
const sorted = entries.sort((a, b) => {
const timeA = this.timestamps.get(a[0]) || 0;
const timeB = this.timestamps.get(b[0]) || 0;
return timeA - timeB;
});
// Evict oldest entries until under limit
let currentSize = totalSize;
for (const [key, value] of sorted) {
if (currentSize <= this.config.maxCacheSize * 0.8) {
break; // Evict to 80% of max
}
const size = JSON.stringify(value).length;
this.invalidate(key);
currentSize -= size;
if (this.debug) {
console.log(`[DataCache] Evicted: ${key} (size: ${size})`);
}
}
}
}
/**
* Get cache metrics
* @returns {Object} Cache metrics
*/
getMetrics() {
const hitRate = this.metrics.hits + this.metrics.misses > 0
? (this.metrics.hits / (this.metrics.hits + this.metrics.misses) * 100).toFixed(2)
: 0;
return {
...this.metrics,
hitRate: `${hitRate}%`,
cacheSize: this.cache.size,
pendingRequests: this.pending.size
};
}
/**
* Reset metrics
*/
resetMetrics() {
this.metrics = {
hits: 0,
misses: 0,
loads: 0,
errors: 0
};
}
/**
* Preload cache hash data
* @returns {Promise<void>}
*/
async preloadCacheHash() {
const key = CacheKeys.CACHE_HASH;
// Skip if already loading or ready
const status = this.getStatus(key);
if (status === CacheStatus.LOADING || status === CacheStatus.READY) {
if (this.debug) {
console.log(`[DataCache] Skip preload (${status}): ${key}`);
}
return;
}
this.setStatus(key, CacheStatus.LOADING);
this.metrics.loads++;
try {
if (this.debug) {
console.log(`[DataCache] Preloading: ${key}`);
}
// Import API function dynamically
const { fetchCacheHash } = await import('./api/cacheApi.js');
const data = await fetchCacheHash();
this.set(key, data);
if (this.debug) {
console.log(`[DataCache] Preloaded: ${key} (${Object.keys(data).length} entries)`);
}
} catch (error) {
console.error(`[DataCache] Error preloading ${key}:`, error);
this.setStatus(key, CacheStatus.ERROR);
this.metrics.errors++;
}
}
/**
* Preload cache info data
* @returns {Promise<void>}
*/
async preloadCacheInfo() {
const key = CacheKeys.CACHE_INFO;
// Skip if already loading or ready
const status = this.getStatus(key);
if (status === CacheStatus.LOADING || status === CacheStatus.READY) {
if (this.debug) {
console.log(`[DataCache] Skip preload (${status}): ${key}`);
}
return;
}
this.setStatus(key, CacheStatus.LOADING);
this.metrics.loads++;
try {
if (this.debug) {
console.log(`[DataCache] Preloading: ${key}`);
}
// Import API function dynamically
const { fetchCacheInfo } = await import('./api/cacheApi.js');
const data = await fetchCacheInfo();
this.set(key, data);
if (this.debug) {
console.log(`[DataCache] Preloaded: ${key} (${Object.keys(data).length} entries)`);
}
} catch (error) {
console.error(`[DataCache] Error preloading ${key}:`, error);
this.setStatus(key, CacheStatus.ERROR);
this.metrics.errors++;
}
}
/**
* Preload gallery images for a specific folder
* @param {string} folder - Folder name (notes, input, output)
* @returns {Promise<void>}
*/
async preloadGalleryImages(folder = 'notes') {
const key = `galleryImages:${folder}`;
// Skip if already loading or ready
const status = this.getStatus(key);
if (status === CacheStatus.LOADING || status === CacheStatus.READY) {
if (this.debug) {
console.log(`[DataCache] Skip preload (${status}): ${key}`);
}
return;
}
this.setStatus(key, CacheStatus.LOADING);
this.metrics.loads++;
try {
if (this.debug) {
console.log(`[DataCache] Preloading: ${key}`);
}
// Import API function dynamically
const { loadImagesFromFolder } = await import('./api/galleryApi.js');
const result = await loadImagesFromFolder(folder, null, (msg) => {
if (this.debug && (msg.includes('Loading') || msg.includes('Complete'))) {
console.log(`[DataCache] ${key}: ${msg}`);
}
});
this.set(key, result);
if (this.debug) {
console.log(`[DataCache] Preloaded: ${key} (${result.images.length} images, ${result.folders.length} folders)`);
}
} catch (error) {
console.error(`[DataCache] Error preloading ${key}:`, error);
this.setStatus(key, CacheStatus.ERROR);
this.metrics.errors++;
}
}
/**
* Preload settings
* @returns {Promise<void>}
*/
async preloadSettings() {
const key = CacheKeys.SETTINGS;
// Skip if already loading or ready
const status = this.getStatus(key);
if (status === CacheStatus.LOADING || status === CacheStatus.READY) {
if (this.debug) {
console.log(`[DataCache] Skip preload (${status}): ${key}`);
}
return;
}
this.setStatus(key, CacheStatus.LOADING);
this.metrics.loads++;
try {
if (this.debug) {
console.log(`[DataCache] Preloading: ${key}`);
}
// Import API dynamically
const { api } = await import('../../../../scripts/api.js');
const response = await api.fetchApi('/sage_utils/settings');
if (response.ok) {
const data = await response.json();
this.set(key, data.settings || {});
if (this.debug) {
console.log(`[DataCache] Preloaded: ${key}`);
}
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
console.error(`[DataCache] Error preloading ${key}:`, error);
this.setStatus(key, CacheStatus.ERROR);
this.metrics.errors++;
}
}
/**
* Preload all critical data
* @returns {Promise<void>}
*/
async preloadAll() {
const startTime = performance.now();
if (this.debug) {
console.log('[DataCache] Starting preload of all critical data...');
}
// Determine preferred gallery folder from persisted state (fallback to notes)
let preferredFolder = 'notes';
try {
const stored = localStorage.getItem('sageutils_sidebar_state');
if (stored) {
const parsed = JSON.parse(stored);
const selected = parsed?.gallery?.selectedFolder;
const currentPath = parsed?.gallery?.currentPath;
if (selected && selected !== 'custom') {
preferredFolder = selected;
} else if (selected === 'custom') {
// Only preload custom if a path exists (DataCache.preloadGalleryImages doesn't take a path yet), otherwise fallback
if (currentPath && currentPath.trim() !== '') {
// Fallback: still use notes as DataCache cannot preload custom paths
preferredFolder = 'notes';
}
}
}
} catch (e) {
// Ignore parse errors and fallback to notes
}
// Preload in parallel
await Promise.allSettled([
this.preloadCacheHash(),
this.preloadCacheInfo(),
this.preloadGalleryImages(preferredFolder),
this.preloadSettings()
]);
const duration = performance.now() - startTime;
if (this.debug) {
console.log(`[DataCache] Preload complete in ${duration.toFixed(2)}ms`);
console.log('[DataCache] Metrics:', this.getMetrics());
}
}
/**
* Get formatted cache summary
* @returns {string} Cache summary
*/
getSummary() {
const metrics = this.getMetrics();
const statuses = {};
Object.values(CacheKeys).forEach(key => {
const status = this.getStatus(key);
statuses[status] = (statuses[status] || 0) + 1;
});
return `DataCache Summary:
- Cache Size: ${metrics.cacheSize} entries
- Hit Rate: ${metrics.hitRate}
- Hits: ${metrics.hits}, Misses: ${metrics.misses}
- Loads: ${metrics.loads}, Errors: ${metrics.errors}
- Pending: ${metrics.pendingRequests}
- Status: ${Object.entries(statuses).map(([k, v]) => `${k}=${v}`).join(', ')}`;
}
}
// Export singleton instance
export const DataCache = new DataCacheClass();
// Export for debugging
if (typeof window !== 'undefined') {
window.SageUtils_DataCache = DataCache;
}
export default DataCache;