diff --git a/public/cynthia1.webp b/public/cynthia1.webp new file mode 100644 index 0000000..aa4e856 Binary files /dev/null and b/public/cynthia1.webp differ diff --git a/public/darling.webp b/public/darling.webp new file mode 100644 index 0000000..4a04723 Binary files /dev/null and b/public/darling.webp differ diff --git a/public/granny.webp b/public/granny.webp new file mode 100644 index 0000000..7c94251 Binary files /dev/null and b/public/granny.webp differ diff --git a/public/numi_lily.webp b/public/numi_lily.webp new file mode 100644 index 0000000..65019ba Binary files /dev/null and b/public/numi_lily.webp differ diff --git a/src/config/backgrounds.ts b/src/config/backgrounds.ts index e85074f..ea5bd65 100644 --- a/src/config/backgrounds.ts +++ b/src/config/backgrounds.ts @@ -33,7 +33,7 @@ export const backgrounds: BackgroundImage[] = [ source: "https://danbooru.donmai.us/posts/6900673", socials: [ { - name: "Twitter", + name: "Artist's Twitter", link: "https://twitter.com/ayanagi0319", }, ], @@ -45,7 +45,7 @@ export const backgrounds: BackgroundImage[] = [ source: "https://danbooru.donmai.us/posts/8743208", socials: [ { - name: "Twitter", + name: "Artist's Twitter", link: "https://x.com/kamepan44231", }, ], @@ -57,7 +57,7 @@ export const backgrounds: BackgroundImage[] = [ source: "https://danbooru.donmai.us/posts/8074444", socials: [ { - name: "Twitter", + name: "Artist's Twitter", link: "https://x.com/Ten_0123_/", }, ], @@ -69,8 +69,8 @@ export const backgrounds: BackgroundImage[] = [ source: "https://www.pixiv.net/en/artworks/124222053", socials: [ { - name: "Twitter", - link: "https://twitter.com/greatodoggo", + name: "Artist's Twitter", + link: "https://x.com/greatodoggo", }, ], }, @@ -81,7 +81,7 @@ export const backgrounds: BackgroundImage[] = [ source: "https://safebooru.org/index.php?page=post&s=view&id=5418793", socials: [ { - name: "Twitter", + name: "Artist's Twitter", link: "https://x.com/soaphisoap", }, ], @@ -93,7 +93,7 @@ export const backgrounds: BackgroundImage[] = [ source: "https://safebooru.org/index.php?page=post&s=view&id=5531630", socials: [ { - name: "Twitter", + name: "Artist's Twitter", link: "https://x.com/charlieinoni", }, ], @@ -106,11 +106,59 @@ export const backgrounds: BackgroundImage[] = [ "https://i.pximg.net/img-original/img/2025/03/03/15/13/24/127822472_p0.png", socials: [ { - name: "Twitter", + name: "Artist's Twitter", link: "https://x.com/", }, ], }, + { + url: "/cynthia1.webp", + title: "Cynthia (Pokemon)", + artist: "???", + source: "https://safebooru.org/index.php?page=post&s=view&id=5547740", + socials: [ + { + name: "Artist's Twitter", + link: "https://x.com/", + }, + ], + }, + { + url: "/darling.webp", + title: "Zero Two", + artist: "AWZ", + source: "https://www.pixiv.net/en/artworks/123110068", + socials: [ + { + name: "Pixiv Profile", + link: "https://www.pixiv.net/en/users/67355371", + }, + ], + }, + { + url: "/granny.webp", + title: "Turbo Granny (Dandadan)", + artist: "MajimartBCN", + source: "https://twitter.com/MajimartBCN/status/1864741369383694841", + socials: [ + { + name: "Artist's Twitter", + link: "https://x.com/MajimartBCN", + }, + ], + }, + { + url: "/numi_lily.webp", + title: "Nihmune & Shylily", + artist: "Gryever_AW", + source: "https://x.com/gryever_aw/status/1890772686084686118", + socials: [ + { + name: "Artist's Twitter", + link: "https://x.com/Gryever_AW", + }, + ], + }, // Add more backgrounds here! // { // url: 'your-image-url', diff --git a/src/lib/db.ts b/src/lib/db.ts index e28ea3c..32cea62 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -7,6 +7,10 @@ export const CHAPTER_CACHE_TTL = 60 * 60; // 1 hour const MAX_RETRIES = 3; const BASE_DELAY_MS = 50; const IS_VERCEL = process.env.VERCEL || false; +const HEALTH_CHECK_INTERVAL = 60 * 1000; // Check primary health every minute +const REPLICA_PROMOTION_THRESHOLD = 5; // After 5 failed primary health checks, promote a replica to primary +const SYNC_BUFFER_SIZE = 100; // Maximum number of operations to buffer before syncing +const SYNC_INTERVAL = 10 * 1000; // Sync replicas every 10 seconds // Redis URLs from environment variables const PRIMARY_REDIS_URL = @@ -18,13 +22,35 @@ const REPLICA_URLS = [ import.meta.env.REPLICA_REDIS_URL_1 || process.env.REPLICA_REDIS_URL_1 || import.meta.env.REPLICA_REDIS_URL, - import.meta.env.REPLICA_REDIS_URL_2 || process.env.REPLICA_REDIS_URL_2, + import.meta.env.REPLICA_REDIS_URL_2 || process.env.REPLICA_REPLICA_URL_2 || process.env.REPLICA_REDIS_URL_2, import.meta.env.REPLICA_REDIS_URL_3 || process.env.REPLICA_REDIS_URL_3, ].filter(Boolean) as string[]; -// Singleton clients +// Log available Redis instances +console.log(`Primary Redis URL: ${PRIMARY_REDIS_URL ? 'Available' : 'Not configured'}`); +console.log(`Replica URLs available: ${REPLICA_URLS.length}`); + +// Singleton clients with health status tracking let primaryClient: Redis | null = null; +let primaryHealthy: boolean = true; +let primaryFailedChecks = 0; let replicaClients: Map = new Map(); +let activeReplicaUrl: string | null = null; + +// Track which replica was used when primary was down +let lastUsedReplica: string | null = null; + +// Operation buffer for sync +interface RedisOperation { + key: string; + value: string; + ttl?: number; + type: 'set' | 'del'; + timestamp: number; +} + +// Buffer to store operations that need to be synced +const operationBuffer: RedisOperation[] = []; // Redis instance naming for logging const redisInstanceNames = new Map(); @@ -57,6 +83,7 @@ async function createRedisClient(url: string): Promise { throw new Error("Redis URL not provided"); } + console.log(`Creating Redis client for ${getRedisName(url)}: ${url.slice(0, 20)}...`); const redisName = getRedisName(url); const client = new Redis(url, { @@ -82,14 +109,66 @@ async function createRedisClient(url: string): Promise { // Setup event listeners client - .on("ready", () => console.log(`${redisName} ready`)) - .on("error", (err) => console.error(`${redisName} error:`, err)) + .on("ready", () => { + console.log(`${redisName} ready`); + + // If primary recovers and we had used a replica, initiate sync from replica to primary + if (url === PRIMARY_REDIS_URL && lastUsedReplica) { + syncFromReplicaToPrimary(lastUsedReplica).catch(err => + console.error(`Failed to sync from replica to primary: ${err}`)); + } + }) + .on("error", (err) => { + console.error(`${redisName} error:`, err); + if (url === PRIMARY_REDIS_URL) { + primaryHealthy = false; + } + }) .on("reconnecting", () => console.log(`${redisName} reconnecting`)) - .on("end", () => console.log(`${redisName} closed`)); + .on("end", () => { + console.log(`${redisName} closed`); + if (url === PRIMARY_REDIS_URL) { + primaryHealthy = false; + } + }); return client; } +/** + * Initialize all replicas to establish connections early + */ +async function initializeAllClients(): Promise { + try { + // Initialize primary + if (PRIMARY_REDIS_URL && (!primaryClient || primaryClient.status !== "ready")) { + primaryClient = await createRedisClient(PRIMARY_REDIS_URL); + primaryHealthy = await checkConnection(primaryClient, "Primary"); + console.log(`Primary Redis initialized, healthy: ${primaryHealthy}`); + } + + // Initialize all replicas + for (const url of REPLICA_URLS) { + if (!replicaClients.has(url)) { + try { + const client = await createRedisClient(url); + const isHealthy = await checkConnection(client, getRedisName(url)); + if (isHealthy) { + replicaClients.set(url, client); + console.log(`Replica ${getRedisName(url)} initialized successfully`); + } + } catch (error) { + console.error(`Failed to initialize replica ${getRedisName(url)}:`, error); + } + } + } + + console.log(`Initialized ${replicaClients.size} replica connections`); + } catch (error) { + console.error("Error during client initialization:", error); + } +} + /** * Check if a Redis client is connected and responsive */ @@ -110,69 +189,257 @@ async function checkConnection(client: Redis, name: string): Promise { /** * Get an active Redis client from the pool + * Prioritizes primary, falls back to replicas */ export async function getRedisClient(): Promise { - // Try primary client - if (!primaryClient || primaryClient.status !== "ready") { - primaryClient = await createRedisClient(PRIMARY_REDIS_URL!); - } - - if (await checkConnection(primaryClient, "Primary")) { + // Try primary client first if it's considered healthy + if (primaryHealthy && (primaryClient === null || primaryClient.status !== "ready")) { + try { + primaryClient = await createRedisClient(PRIMARY_REDIS_URL!); + const isPrimaryConnected = await checkConnection(primaryClient, "Primary"); + primaryHealthy = isPrimaryConnected; + if (isPrimaryConnected) { + primaryFailedChecks = 0; + console.log("Initialized primary Redis connection"); + return primaryClient; + } + } catch (err) { + console.error("Failed to initialize primary Redis:", err); + primaryHealthy = false; + primaryFailedChecks++; + } + } else if (primaryHealthy && primaryClient && primaryClient.status === "ready") { return primaryClient; } - // Try replicas - for (const url of REPLICA_URLS) { - let client = replicaClients.get(url); - if (!client || client.status !== "ready") { - client = await createRedisClient(url); - replicaClients.set(url, client); - } - - if (await checkConnection(client, getRedisName(url))) { - return client; + // Primary is unhealthy, try to use the active replica if we have one + if (activeReplicaUrl && replicaClients.has(activeReplicaUrl)) { + const activeReplica = replicaClients.get(activeReplicaUrl)!; + if (activeReplica.status === "ready" && await checkConnection(activeReplica, getRedisName(activeReplicaUrl))) { + console.log(`Using active replica: ${getRedisName(activeReplicaUrl)}`); + lastUsedReplica = activeReplicaUrl; // Track that we used this replica + return activeReplica; } } - // Fallback: Force new primary connection + // No active replica, try replicas in order + for (const url of REPLICA_URLS) { + try { + let client = replicaClients.get(url); + if (!client || client.status !== "ready") { + client = await createRedisClient(url); + replicaClients.set(url, client); + } + + if (await checkConnection(client, getRedisName(url))) { + console.log(`Switching to replica: ${getRedisName(url)}`); + activeReplicaUrl = url; + lastUsedReplica = url; // Track that we used this replica + + // If primary has been down for a while, promote this replica to be the new primary + if (primaryFailedChecks >= REPLICA_PROMOTION_THRESHOLD) { + console.log(`Promoting ${getRedisName(url)} to primary after ${primaryFailedChecks} failed checks`); + primaryClient = client; + primaryHealthy = true; + primaryFailedChecks = 0; + } + + return client; + } + } catch (err) { + console.error(`Failed to connect to replica ${getRedisName(url)}:`, err); + } + } + + // If all else fails, try one more time with primary + console.warn("All Redis connections failed, making last attempt with primary"); primaryClient = await createRedisClient(PRIMARY_REDIS_URL!); return primaryClient; } /** - * Write data to all available Redis instances + * Get all available replicas that are not the active client */ -async function writeToAllRedis( - operation: (redis: Redis) => Promise -): Promise { - const clients = [ - primaryClient, - ...Array.from(replicaClients.values()), - ].filter(Boolean) as Redis[]; +async function getAvailableReplicas(activeClientUrl: string): Promise { + const availableReplicas: Redis[] = []; - const errors: Error[] = []; - - for (const client of clients) { - try { - if (client.status === "ready") { - await operation(client); + // Initialize clients if not already done + await initializeAllClients(); + + // Check each replica + for (const [url, client] of replicaClients.entries()) { + if (url !== activeClientUrl && client.status === "ready") { + try { + const isHealthy = await checkConnection(client, getRedisName(url)); + if (isHealthy) { + availableReplicas.push(client); + } + } catch (error) { + console.error(`Error checking replica ${getRedisName(url)}:`, error); } - } catch (err) { - errors.push(err as Error); - console.error( - `Write failed for ${getRedisName(client.options.host || "")}:`, - err - ); } } + + console.log(`Found ${availableReplicas.length} available replicas for replication`); + return availableReplicas; +} - if (errors.length === clients.length && clients.length > 0) { - throw new Error("All Redis writes failed"); +/** + * Immediately replicate a Redis operation to all available replicas + */ +async function replicateOperation( + activeClientUrl: string, + key: string, + value: string, + ttl?: number +): Promise { + try { + // Get available replicas that are not the active client + const replicas = await getAvailableReplicas(activeClientUrl); + + if (replicas.length === 0) { + console.log('No available replicas for replication'); + return; + } + + console.log(`Replicating operation for key ${key} to ${replicas.length} replicas`); + + // Replicate to each available replica + const replicationPromises = replicas.map(async (replica) => { + try { + if (ttl) { + await replica.set(key, value, 'EX', ttl); + } else { + await replica.set(key, value); + } + return true; + } catch (error) { + console.error(`Replication failed for key ${key}:`, error); + return false; + } + }); + + const results = await Promise.allSettled(replicationPromises); + const successful = results.filter(r => r.status === 'fulfilled' && r.value === true).length; + + console.log(`Successfully replicated to ${successful}/${replicas.length} replicas`); + } catch (error) { + console.error('Error during replication:', error); } } /** - * Store a feed mapping in Redis + * Synchronize data from a replica to the primary + * Used when primary recovers after being down + */ +async function syncFromReplicaToPrimary(replicaUrl: string): Promise { + if (!primaryClient || primaryClient.status !== "ready" || !replicaClients.has(replicaUrl)) { + console.log("Cannot sync: primary or replica not available"); + return; + } + + const replicaClient = replicaClients.get(replicaUrl)!; + console.log(`Starting sync from ${getRedisName(replicaUrl)} to Primary...`); + + try { + // Get all feed keys using scan to prevent blocking + const feedKeys: string[] = []; + let cursor = '0'; + + do { + const [nextCursor, keys] = await replicaClient.scan( + cursor, + 'MATCH', + '[0-9a-f]*', // Match feed IDs (hex format) + 'COUNT', + '100' + ); + + cursor = nextCursor; + feedKeys.push(...keys); + } while (cursor !== '0'); + + // Also try scanning for any key pattern that might be a feed ID + cursor = '0'; + const additionalKeys: string[] = []; + + do { + const [nextCursor, keys] = await replicaClient.scan( + cursor, + 'MATCH', + 'feed:*', // Try another pattern + 'COUNT', + '100' + ); + + cursor = nextCursor; + additionalKeys.push(...keys); + } while (cursor !== '0'); + + feedKeys.push(...additionalKeys); + + // Get chapter cache keys + const chapterKeys: string[] = []; + cursor = '0'; + + do { + const [nextCursor, keys] = await replicaClient.scan( + cursor, + 'MATCH', + 'chapters:*', + 'COUNT', + '100' + ); + + cursor = nextCursor; + chapterKeys.push(...keys); + } while (cursor !== '0'); + + console.log(`Found ${feedKeys.length} feed keys and ${chapterKeys.length} chapter keys to sync`); + + // Sync feed mappings + if (feedKeys.length > 0) { + const feedPipeline = primaryClient.pipeline(); + + for (const key of feedKeys) { + const value = await replicaClient.get(key); + const ttl = await replicaClient.ttl(key); + + if (value && ttl > 0) { + feedPipeline.set(key, value, 'EX', ttl); + } + } + + await feedPipeline.exec(); + console.log(`Synced ${feedKeys.length} feed mappings from replica to primary`); + } + + // Sync chapter cache + if (chapterKeys.length > 0) { + const chapterPipeline = primaryClient.pipeline(); + + for (const key of chapterKeys) { + const value = await replicaClient.get(key); + const ttl = await replicaClient.ttl(key); + + if (value && ttl > 0) { + chapterPipeline.set(key, value, 'EX', ttl); + } + } + + await chapterPipeline.exec(); + console.log(`Synced ${chapterKeys.length} chapter caches from replica to primary`); + } + + lastUsedReplica = null; // Reset after successful sync + console.log("Replica to primary sync completed successfully"); + } catch (error) { + console.error("Error during replica to primary sync:", error); + } +} + +/** + * Store a feed mapping in Redis with immediate replication */ export async function storeFeedMapping( feedId: string, @@ -185,31 +452,54 @@ export async function storeFeedMapping( created_at: new Date().toISOString() }; - await writeToAllRedis(async (redis) => - redis.set(feedId, JSON.stringify(mapping), "EX", DEFAULT_CACHE_TTL) - ); + const stringValue = JSON.stringify(mapping); + + try { + // First write to primary/active client + const redis = await getRedisClient(); // This already handles failover + const activeUrl = redis.options.host || ''; + + await redis.set(feedId, stringValue, "EX", DEFAULT_CACHE_TTL); + console.log(`Successfully stored mapping for feed ${feedId}`); + + // Immediately replicate to other instances + await replicateOperation(activeUrl, feedId, stringValue, DEFAULT_CACHE_TTL); + } catch (error) { + console.error(`Error storing feed mapping for ${feedId}:`, error); + throw error; + } } /** - * Retrieve a feed mapping from Redis + * Retrieve a feed mapping from Redis with automatic failover */ export async function getFeedMapping( feedId: string ): Promise { try { - const redis = await getRedisClient(); + const redis = await getRedisClient(); // This already handles failover const mapping = await redis.get(feedId); if (!mapping) return null; // Reset expiration on access + const activeUrl = redis.options.host || ''; await redis.expire(feedId, DEFAULT_CACHE_TTL); - const data = JSON.parse(mapping); - return { - slugs: data.slugs, - lang: data.lang - }; + // Also reset TTL on other replicas + replicateOperation(activeUrl, feedId, mapping, DEFAULT_CACHE_TTL) + .catch(err => console.error(`Failed to reset TTL on replicas for ${feedId}:`, err)); + + try { + const data = JSON.parse(mapping); + return { + slugs: data.slugs, + lang: data.lang + }; + } catch (e) { + console.error(`Error parsing feed mapping for ${feedId}:`, e); + return null; + } } catch (error) { console.error(`Error retrieving feed mapping for ${feedId}:`, error); return null; @@ -227,7 +517,7 @@ export async function warmChapterCache( ): Promise { try { console.log(`Warming chapter cache for ${slugs.length} comics in ${lang}`); - const redis = await getRedisClient(); + const redis = await getRedisClient(); // This already handles failover // Validate slugs const validSlugs = slugs.filter(slug => @@ -281,5 +571,12 @@ export async function warmChapterCache( } } -// Initialize connections lazily +// Initialize connections eagerly to establish connections as soon as possible +if (!import.meta.env.SSR) { + initializeAllClients() + .then(() => console.log("Redis clients initialized")) + .catch(err => console.error("Failed to initialize Redis clients:", err)); +} + +// Initialize connections lazily as backup console.log("Redis will connect on first use");