mirror of
https://github.com/ld3z/new-manga.git
synced 2026-11-09 17:43:44 -07:00
966 lines
36 KiB
TypeScript
966 lines
36 KiB
TypeScript
import Redis from "ioredis";
|
|
import type { FeedMapping } from "./types";
|
|
import { ReJSON } from 'redis-modules-sdk';
|
|
import * as zlib from 'zlib';
|
|
import { promisify } from 'util';
|
|
|
|
// Configuration constants
|
|
const DEFAULT_CACHE_TTL = 60 * 60 * 24 * 30; // 30 days
|
|
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 =
|
|
import.meta.env.PRIMARY_REDIS_URL ||
|
|
process.env.PRIMARY_REDIS_URL ||
|
|
process.env.REDIS_URL;
|
|
|
|
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_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[];
|
|
|
|
// 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<string, Redis> = 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<string, string>();
|
|
|
|
// Add zlib for compression
|
|
const gzip = promisify(zlib.gzip);
|
|
const gunzip = promisify(zlib.gunzip);
|
|
|
|
// Compression threshold (in bytes)
|
|
const COMPRESSION_THRESHOLD = 1024; // 1KB
|
|
|
|
// Helper function to determine if compression should be used
|
|
function shouldCompress(data: string): boolean {
|
|
return Buffer.byteLength(data, 'utf8') > COMPRESSION_THRESHOLD;
|
|
}
|
|
|
|
/**
|
|
* Initialize Redis instance names for better logging
|
|
*/
|
|
function initializeRedisNames() {
|
|
if (PRIMARY_REDIS_URL) {
|
|
redisInstanceNames.set(PRIMARY_REDIS_URL, "Primary");
|
|
}
|
|
|
|
REPLICA_URLS.forEach((url, index) =>
|
|
redisInstanceNames.set(url, `Replica ${index + 1}`)
|
|
);
|
|
}
|
|
|
|
initializeRedisNames();
|
|
|
|
/**
|
|
* Get the friendly name of a Redis instance
|
|
*/
|
|
const getRedisName = (url: string) => redisInstanceNames.get(url) || "Unknown";
|
|
|
|
// Add at the top of your file, after the imports
|
|
declare module "ioredis" {
|
|
interface Redis {
|
|
json_set(key: string, path: string, json: any): Promise<string>;
|
|
json_get(key: string, ...paths: string[]): Promise<string>;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new Redis client with optimized settings
|
|
*/
|
|
async function createRedisClient(url: string): Promise<Redis> {
|
|
if (!url) {
|
|
throw new Error("Redis URL not provided");
|
|
}
|
|
|
|
const redisName = getRedisName(url);
|
|
|
|
const client = new Redis(url, {
|
|
retryStrategy: (times) => {
|
|
if (times >= MAX_RETRIES) return null;
|
|
return Math.min(BASE_DELAY_MS * Math.pow(1.5, times), 1000);
|
|
},
|
|
maxRetriesPerRequest: 2,
|
|
enableOfflineQueue: true,
|
|
enableReadyCheck: true,
|
|
connectTimeout: 3000,
|
|
keepAlive: 5000,
|
|
commandTimeout: 3000,
|
|
reconnectOnError: (err) =>
|
|
["READONLY", "ETIMEDOUT", "ECONNRESET"].some((e) =>
|
|
err.message.includes(e)
|
|
),
|
|
});
|
|
|
|
// Setup event listeners
|
|
client
|
|
.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`);
|
|
if (url === PRIMARY_REDIS_URL) {
|
|
primaryHealthy = false;
|
|
}
|
|
});
|
|
|
|
return client;
|
|
}
|
|
|
|
/**
|
|
* Initialize all replicas to establish connections early
|
|
*/
|
|
async function initializeAllClients(): Promise<void> {
|
|
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
|
|
*/
|
|
async function checkConnection(client: Redis, name: string): Promise<boolean> {
|
|
try {
|
|
await Promise.race([
|
|
client.ping(),
|
|
new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error("Ping timeout")), 2000)
|
|
),
|
|
]);
|
|
return true;
|
|
} catch (err) {
|
|
console.warn(`${name} ping failed:`, err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get an active Redis client from the pool
|
|
* Prioritizes primary, falls back to replicas
|
|
* Returns the client and its connection URL.
|
|
*/
|
|
export async function getRedisClient(): Promise<{ client: Redis; url: string }> {
|
|
// 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 { client: primaryClient, url: PRIMARY_REDIS_URL! }; // <-- Return URL
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to initialize primary Redis:", err);
|
|
primaryHealthy = false;
|
|
primaryFailedChecks++;
|
|
}
|
|
} else if (primaryHealthy && primaryClient && primaryClient.status === "ready") {
|
|
// Check connection just in case before returning
|
|
if (await checkConnection(primaryClient, "Primary")) {
|
|
return { client: primaryClient, url: PRIMARY_REDIS_URL! }; // <-- Return URL
|
|
} else {
|
|
console.warn("Primary client status was ready but ping failed. Marking unhealthy.");
|
|
primaryHealthy = false;
|
|
primaryFailedChecks++;
|
|
// Fall through to replica logic
|
|
}
|
|
}
|
|
|
|
// 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 { client: activeReplica, url: activeReplicaUrl }; // <-- Return URL
|
|
}
|
|
}
|
|
|
|
// 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; // The client instance is now considered primary
|
|
primaryHealthy = true;
|
|
primaryFailedChecks = 0;
|
|
// Note: PRIMARY_REDIS_URL still points to the original primary config.
|
|
// The 'url' returned here is the replica's URL, which is correct for identifying the *current* primary instance.
|
|
}
|
|
|
|
return { client: client, url: url }; // <-- Return URL
|
|
}
|
|
} 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");
|
|
// Ensure primaryClient is re-initialized if it failed earlier
|
|
if (!primaryClient || primaryClient.status !== 'connecting' && primaryClient.status !== 'ready') {
|
|
try {
|
|
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
|
|
} catch (err) {
|
|
console.error("Fatal: Failed to re-initialize primary Redis on last attempt:", err);
|
|
throw new Error("Could not establish any Redis connection."); // Throw if even last attempt fails
|
|
}
|
|
}
|
|
// Even if creation succeeded, we return it, subsequent operations might fail but we need to return *something*
|
|
return { client: primaryClient, url: PRIMARY_REDIS_URL! }; // <-- Return URL
|
|
}
|
|
|
|
/**
|
|
* Get all available replicas that are not the active client
|
|
*/
|
|
async function getAvailableReplicas(activeClientUrl: string): Promise<Redis[]> {
|
|
const availableReplicas: Redis[] = [];
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Found ${availableReplicas.length} available replicas for replication`);
|
|
return availableReplicas;
|
|
}
|
|
|
|
/**
|
|
* Store a feed mapping in Redis with immediate replication
|
|
*/
|
|
export async function storeFeedMapping(
|
|
feedId: string,
|
|
slugs: string[],
|
|
lang: string
|
|
): Promise<void> {
|
|
const mapping = {
|
|
slugs,
|
|
lang,
|
|
created_at: new Date().toISOString()
|
|
};
|
|
|
|
const stringValue = JSON.stringify(mapping);
|
|
const regularKey = feedId; // Key for the non-compressed version
|
|
const compressedKey = `${feedId}:compressed`; // Key for the compressed version
|
|
|
|
try {
|
|
const { client: redis, url: activeUrl } = await getRedisClient();
|
|
const clientName = getRedisName(activeUrl);
|
|
|
|
// --- 1. Store the non-compressed version ALWAYS ---
|
|
console.log(`[storeFeedMapping] Attempting initial SET for regular key ${regularKey} to ${clientName}`);
|
|
await redis.set(regularKey, stringValue, "EX", DEFAULT_CACHE_TTL);
|
|
console.log(`[storeFeedMapping] SUCCESS: Stored regular mapping for feed ${feedId} to ${clientName}`);
|
|
|
|
// Replicate the non-compressed version
|
|
console.log(`[storeFeedMapping] Calling ensurePrimaryAndReplicate for regular key ${regularKey} (source: ${clientName})`);
|
|
await ensurePrimaryAndReplicate(activeUrl, regularKey, stringValue, DEFAULT_CACHE_TTL);
|
|
|
|
|
|
// --- 2. Store the compressed version IF needed ---
|
|
if (shouldCompress(stringValue)) {
|
|
console.log(`[storeFeedMapping] Compressing large feed mapping for ${feedId} (${Buffer.byteLength(stringValue, 'utf8')} bytes)`);
|
|
const compressed = await gzip(stringValue);
|
|
const base64Compressed = compressed.toString('base64');
|
|
|
|
console.log(`[storeFeedMapping] Attempting initial SET for compressed key ${compressedKey} to ${clientName}`);
|
|
await redis.set(compressedKey, base64Compressed, "EX", DEFAULT_CACHE_TTL);
|
|
console.log(`[storeFeedMapping] SUCCESS: Stored compressed mapping (${base64Compressed.length} bytes) to ${clientName}`);
|
|
|
|
// Replicate the compressed version
|
|
console.log(`[storeFeedMapping] Calling ensurePrimaryAndReplicate for compressed key ${compressedKey} (source: ${clientName})`);
|
|
await ensurePrimaryAndReplicate(activeUrl, compressedKey, base64Compressed, DEFAULT_CACHE_TTL);
|
|
|
|
} else {
|
|
console.log(`[storeFeedMapping] Skipping compressed storage for ${feedId} (data size below threshold).`);
|
|
// Optional: If a compressed version might exist from a previous larger save, delete it
|
|
// console.log(`[storeFeedMapping] Attempting to delete potentially stale compressed key ${compressedKey} on ${clientName}`);
|
|
// await redis.del(compressedKey).catch(err => console.warn(`Failed to delete stale compressed key ${compressedKey}:`, err));
|
|
// // Also replicate the deletion
|
|
// console.log(`[storeFeedMapping] Calling ensurePrimaryAndReplicate for DELETE of compressed key ${compressedKey}`);
|
|
// await ensurePrimaryAndReplicate(activeUrl, compressedKey, '', 0); // Use TTL 0 or a specific marker for deletion if needed
|
|
}
|
|
|
|
console.log(`[storeFeedMapping] Successfully processed storage and replication for feed ${feedId} (both versions if applicable)`);
|
|
|
|
} catch (error) {
|
|
// Log the specific error during the initial write attempt
|
|
// Note: Error could happen during either set operation or replication call
|
|
console.error(`[storeFeedMapping] CRITICAL ERROR storing feed mapping for ${feedId}:`, error);
|
|
throw error; // Re-throw the error after logging
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve a feed mapping from Redis with automatic failover
|
|
*/
|
|
export async function getFeedMapping(
|
|
feedId: string
|
|
): Promise<FeedMapping | null> {
|
|
console.log(`[getFeedMapping] Attempting to get mapping for feedId: ${feedId}`);
|
|
try {
|
|
// Try primary first
|
|
const { client, url: clientUrl } = await getRedisClient();
|
|
const clientName = getRedisName(clientUrl);
|
|
console.log(`[getFeedMapping] Using client: ${clientName} (${clientUrl})`);
|
|
|
|
// Check for compressed version first
|
|
const compressedKey = `${feedId}:compressed`;
|
|
console.log(`[getFeedMapping] Checking for compressed key: ${compressedKey} on ${clientName}`);
|
|
const compressedData = await client.get(compressedKey);
|
|
|
|
if (compressedData) {
|
|
console.log(`[getFeedMapping] SUCCESS: Found compressed data for ${feedId} on ${clientName}`);
|
|
try {
|
|
// Decompress and parse
|
|
const buffer = Buffer.from(compressedData, 'base64');
|
|
const decompressed = await gunzip(buffer);
|
|
const data = JSON.parse(decompressed.toString());
|
|
|
|
// Reset expiration
|
|
console.log(`[getFeedMapping] Resetting TTL for compressed key ${compressedKey} on ${clientName}`);
|
|
await client.expire(compressedKey, DEFAULT_CACHE_TTL);
|
|
|
|
return {
|
|
slugs: data.slugs,
|
|
lang: data.lang
|
|
};
|
|
} catch (error) {
|
|
console.error(`[getFeedMapping] Error decompressing data for ${feedId} from ${clientName}:`, error);
|
|
// Fall through to try uncompressed version
|
|
}
|
|
} else {
|
|
console.log(`[getFeedMapping] No compressed key found for ${feedId} on ${clientName}.`);
|
|
}
|
|
|
|
// If no compressed version, try regular key
|
|
const regularKey = feedId;
|
|
console.log(`[getFeedMapping] Checking for regular key: ${regularKey} on ${clientName}`);
|
|
let jsonString = await client.get(regularKey);
|
|
|
|
if (jsonString) {
|
|
console.log(`[getFeedMapping] SUCCESS: Found regular key ${regularKey} on ${clientName}`);
|
|
}
|
|
|
|
// If not found in the current client AND the client was the primary, check replicas
|
|
if (!jsonString && REPLICA_URLS.length > 0 && client === primaryClient) {
|
|
console.log(`[getFeedMapping] Key ${regularKey} not found on PRIMARY, checking replicas...`);
|
|
|
|
// Try each replica
|
|
for (const [replicaUrl, replicaClient] of replicaClients.entries()) {
|
|
const replicaName = getRedisName(replicaUrl);
|
|
if (replicaClient.status === "ready") {
|
|
try {
|
|
console.log(`[getFeedMapping] Checking replica ${replicaName} for key ${regularKey}`);
|
|
// Check compressed key on replica first
|
|
const replicaCompressedData = await replicaClient.get(compressedKey);
|
|
if (replicaCompressedData) {
|
|
console.log(`[getFeedMapping] SUCCESS: Found COMPRESSED key ${compressedKey} on REPLICA ${replicaName}`);
|
|
try {
|
|
const buffer = Buffer.from(replicaCompressedData, 'base64');
|
|
const decompressed = await gunzip(buffer);
|
|
const data = JSON.parse(decompressed.toString());
|
|
// Sync back to primary
|
|
if (primaryClient && primaryClient.status === "ready") {
|
|
console.log(`[getFeedMapping] Syncing COMPRESSED key ${compressedKey} from ${replicaName} back to PRIMARY`);
|
|
primaryClient.set(compressedKey, replicaCompressedData, "EX", DEFAULT_CACHE_TTL)
|
|
.catch(err => console.error(`[getFeedMapping] Failed to sync compressed key ${compressedKey} to primary:`, err));
|
|
}
|
|
return { slugs: data.slugs, lang: data.lang };
|
|
} catch (error) {
|
|
console.error(`[getFeedMapping] Error decompressing data for ${feedId} from REPLICA ${replicaName}:`, error);
|
|
}
|
|
} else {
|
|
console.log(`[getFeedMapping] No compressed key found on replica ${replicaName}. Checking regular key ${regularKey}...`);
|
|
jsonString = await replicaClient.get(regularKey);
|
|
if (jsonString) {
|
|
console.log(`[getFeedMapping] SUCCESS: Found REGULAR key ${regularKey} on REPLICA ${replicaName}`);
|
|
|
|
// Sync this key back to primary for future requests
|
|
if (primaryClient && primaryClient.status === "ready") {
|
|
console.log(`[getFeedMapping] Syncing REGULAR key ${regularKey} from ${replicaName} back to PRIMARY`);
|
|
primaryClient.set(regularKey, jsonString, "EX", DEFAULT_CACHE_TTL)
|
|
.catch(err => console.error(`[getFeedMapping] Failed to sync regular key ${regularKey} to primary:`, err));
|
|
}
|
|
|
|
break; // Found on this replica, stop checking others
|
|
} else {
|
|
console.log(`[getFeedMapping] No regular key found on replica ${replicaName}.`);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`[getFeedMapping] Error checking replica ${replicaName} for ${feedId}:`, error);
|
|
}
|
|
} else {
|
|
console.log(`[getFeedMapping] Skipping replica ${replicaName} (status: ${replicaClient.status})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!jsonString) {
|
|
console.log(`[getFeedMapping] Key ${feedId} (and compressed variant) not found on any checked instance.`);
|
|
return null;
|
|
}
|
|
|
|
// Reset expiration on access (only if found via regular key initially or synced from replica)
|
|
console.log(`[getFeedMapping] Resetting TTL for regular key ${regularKey} on ${clientName}`);
|
|
await client.expire(regularKey, DEFAULT_CACHE_TTL);
|
|
|
|
// Parse the result
|
|
const data = JSON.parse(jsonString as string);
|
|
return {
|
|
slugs: data.slugs,
|
|
lang: data.lang
|
|
};
|
|
} catch (error) {
|
|
console.error(`[getFeedMapping] CRITICAL ERROR retrieving feed mapping for ${feedId}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pre-warm the chapter cache for better performance
|
|
* This function checks cache status and uses the getChaptersForSlugs function from api.ts
|
|
* to fetch and cache data in the background
|
|
*/
|
|
export async function warmChapterCache(
|
|
slugs: string[],
|
|
lang: string
|
|
): Promise<void> {
|
|
try {
|
|
console.log(`Warming chapter cache for ${slugs.length} comics in ${lang}`);
|
|
const { client } = await getRedisClient(); // This already handles failover
|
|
|
|
// Validate slugs
|
|
const validSlugs = slugs.filter(slug =>
|
|
slug && typeof slug === 'string' && slug.match(/^[a-z0-9-]+$/));
|
|
|
|
if (validSlugs.length !== slugs.length) {
|
|
console.warn(`Filtered out ${slugs.length - validSlugs.length} invalid slugs`);
|
|
}
|
|
|
|
// Check which slugs are already in cache
|
|
const pipeline = client.pipeline();
|
|
const cacheKeys = validSlugs.map(slug => `chapters:${slug}:${lang}`);
|
|
|
|
cacheKeys.forEach(key => {
|
|
pipeline.exists(key);
|
|
});
|
|
|
|
const results = await pipeline.exec();
|
|
const slugsToFetch: string[] = [];
|
|
|
|
results?.forEach(([err, exists], index) => {
|
|
if (err) {
|
|
console.error(`Error checking cache for ${cacheKeys[index]}:`, err);
|
|
slugsToFetch.push(validSlugs[index]);
|
|
} else if (!exists) {
|
|
slugsToFetch.push(validSlugs[index]);
|
|
}
|
|
});
|
|
|
|
if (slugsToFetch.length === 0) {
|
|
console.log('All chapters already in cache, no warming needed');
|
|
return;
|
|
}
|
|
|
|
console.log(`Cache miss for ${slugsToFetch.length} comics - starting background fetch`);
|
|
|
|
// To avoid circular dependencies, we'll import getChaptersForSlugs dynamically
|
|
const { getChaptersForSlugs } = await import('./api');
|
|
|
|
// Start fetching in the background without waiting for the result
|
|
getChaptersForSlugs(slugsToFetch, lang)
|
|
.then(chapters => {
|
|
console.log(`Successfully warmed cache with ${chapters.length} chapters`);
|
|
})
|
|
.catch(error => {
|
|
console.error('Error warming chapter cache:', error);
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error in warmChapterCache:', error);
|
|
}
|
|
}
|
|
|
|
// Add a new function for handling large feed mappings with compression
|
|
export async function storeLargeFeedMapping(
|
|
feedId: string,
|
|
slugs: string[],
|
|
lang: string
|
|
): Promise<void> {
|
|
// This function now essentially calls storeFeedMapping which handles compression logic.
|
|
// We can simplify this or keep it as a specific entry point.
|
|
// For consistency, let's just call the main function.
|
|
console.log(`Using storeLargeFeedMapping for ${feedId}, deferring to storeFeedMapping.`);
|
|
await storeFeedMapping(feedId, slugs, lang);
|
|
|
|
/* // Original logic updated with ensurePrimaryAndReplicate:
|
|
const mapping = {
|
|
slugs,
|
|
lang,
|
|
created_at: new Date().toISOString()
|
|
};
|
|
|
|
const jsonString = JSON.stringify(mapping);
|
|
let keyToStore = feedId;
|
|
let valueToStore = jsonString;
|
|
let ttl = DEFAULT_CACHE_TTL;
|
|
|
|
try {
|
|
const redis = await getRedisClient();
|
|
const activeUrl = redis.options.url || `${redis.options.host}:${redis.options.port}`;
|
|
|
|
// Use compression for large mappings
|
|
if (shouldCompress(jsonString)) {
|
|
const compressed = await gzip(jsonString);
|
|
const base64Compressed = compressed.toString('base64');
|
|
|
|
keyToStore = `${feedId}:compressed`;
|
|
valueToStore = base64Compressed;
|
|
|
|
// Store with compression flag
|
|
await redis.set(keyToStore, valueToStore, "EX", ttl);
|
|
console.log(`Stored compressed mapping for feed ${feedId} to ${getRedisName(activeUrl)} (${jsonString.length} → ${valueToStore.length} bytes)`);
|
|
|
|
} else {
|
|
// Use normal storage for small mappings
|
|
await redis.set(keyToStore, jsonString, "EX", ttl);
|
|
console.log(`Stored mapping for feed ${feedId} to ${getRedisName(activeUrl)}`);
|
|
}
|
|
|
|
// Ensure data is on primary and replicate to others
|
|
await ensurePrimaryAndReplicate(activeUrl, keyToStore, valueToStore, ttl);
|
|
|
|
console.log(`Successfully processed storage and replication for large feed ${feedId}`);
|
|
|
|
} catch (error) {
|
|
console.error(`Error storing large feed mapping for ${feedId}:`, error);
|
|
throw error;
|
|
}
|
|
*/
|
|
}
|
|
|
|
// Batch get multiple feed mappings at once
|
|
export async function getBatchFeedMappings(
|
|
feedIds: string[]
|
|
): Promise<Record<string, FeedMapping | null>> {
|
|
if (!feedIds.length) return {};
|
|
|
|
try {
|
|
const { client } = await getRedisClient();
|
|
const pipeline = client.pipeline();
|
|
|
|
// Check regular keys first
|
|
feedIds.forEach(id => {
|
|
pipeline.get(id);
|
|
});
|
|
|
|
const results = await pipeline.exec();
|
|
if (!results) return {};
|
|
|
|
// Process results
|
|
const mappings: Record<string, FeedMapping | null> = {};
|
|
const ttlUpdates = client.pipeline();
|
|
const compressedChecks = client.pipeline();
|
|
const compressedIds: string[] = [];
|
|
|
|
for (let i = 0; i < feedIds.length; i++) {
|
|
const id = feedIds[i];
|
|
const result = results[i];
|
|
|
|
if (result[0]) {
|
|
mappings[id] = null;
|
|
continue;
|
|
}
|
|
|
|
if (result[1]) {
|
|
// Process regular mapping
|
|
try {
|
|
const data = JSON.parse(result[1] as string);
|
|
mappings[id] = {
|
|
slugs: data.slugs,
|
|
lang: data.lang
|
|
};
|
|
|
|
// Reset TTL
|
|
ttlUpdates.expire(id, DEFAULT_CACHE_TTL);
|
|
} catch (e) {
|
|
mappings[id] = null;
|
|
}
|
|
} else {
|
|
// Check for compressed version
|
|
compressedChecks.exists(`${id}:compressed`);
|
|
compressedIds.push(id);
|
|
}
|
|
}
|
|
|
|
// Execute TTL updates in background
|
|
ttlUpdates.exec().catch(err => console.error('TTL update error:', err));
|
|
|
|
// Check compressed versions if needed
|
|
if (compressedIds.length > 0) {
|
|
const compressedResults = await compressedChecks.exec();
|
|
|
|
if (compressedResults) {
|
|
const compressedFetches = client.pipeline();
|
|
const compressedToFetch: string[] = [];
|
|
|
|
compressedResults.forEach((result, index) => {
|
|
if (!result[0] && result[1]) {
|
|
const id = compressedIds[index];
|
|
compressedFetches.get(`${id}:compressed`);
|
|
compressedToFetch.push(id);
|
|
}
|
|
});
|
|
|
|
if (compressedToFetch.length > 0) {
|
|
const fetchResults = await compressedFetches.exec();
|
|
|
|
if (fetchResults) {
|
|
for (let i = 0; i < compressedToFetch.length; i++) {
|
|
const id = compressedToFetch[i];
|
|
const [err, compressedData] = fetchResults[i];
|
|
|
|
if (!err && compressedData) {
|
|
try {
|
|
const buffer = Buffer.from(compressedData as string, 'base64');
|
|
const decompressed = await gunzip(buffer);
|
|
const data = JSON.parse(decompressed.toString());
|
|
|
|
mappings[id] = {
|
|
slugs: data.slugs,
|
|
lang: data.lang
|
|
};
|
|
|
|
// Reset TTL in background
|
|
client.expire(`${id}:compressed`, DEFAULT_CACHE_TTL).catch(() => {});
|
|
} catch (e) {
|
|
mappings[id] = null;
|
|
}
|
|
} else {
|
|
mappings[id] = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return mappings;
|
|
} catch (error) {
|
|
console.error(`Error retrieving batch feed mappings:`, error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensures data is written to the primary (if not the source) and replicated.
|
|
* Handles SET operations. For DEL, a separate function or modification might be needed.
|
|
*/
|
|
async function ensurePrimaryAndReplicate(
|
|
sourceUrl: string,
|
|
key: string,
|
|
value: string, // Value to set
|
|
ttl: number // TTL for SET, use > 0
|
|
): Promise<void> {
|
|
const replicationTasks: Promise<any>[] = [];
|
|
const sourceName = getRedisName(sourceUrl);
|
|
const primaryUrl = PRIMARY_REDIS_URL;
|
|
const primaryName = getRedisName(primaryUrl!);
|
|
|
|
// Check if operation is a delete based on TTL (simple approach)
|
|
const isDelete = ttl <= 0;
|
|
const operationType = isDelete ? 'DELETE' : 'SET';
|
|
|
|
console.log(`[ensurePrimaryAndReplicate] Starting sync for ${operationType} on key ${key}. Source: ${sourceName}`);
|
|
|
|
// 1. Sync to Primary (if source wasn't primary and primary exists/is healthy)
|
|
if (primaryClient && sourceUrl !== primaryUrl) {
|
|
const primaryStatus = primaryClient.status;
|
|
console.log(`[ensurePrimaryAndReplicate] Checking primary (${primaryName}) status: ${primaryStatus}`);
|
|
let primaryIsReady = primaryStatus === 'ready';
|
|
|
|
if (!primaryIsReady) {
|
|
try {
|
|
await primaryClient.ping();
|
|
primaryIsReady = true;
|
|
console.log(`[ensurePrimaryAndReplicate] Primary (${primaryName}) ping successful after status check.`);
|
|
} catch (pingErr) {
|
|
console.warn(`[ensurePrimaryAndReplicate] Primary (${primaryName}) ping failed, skipping sync attempt. Status: ${primaryStatus}`, pingErr);
|
|
}
|
|
}
|
|
|
|
if (primaryIsReady) {
|
|
console.log(`[ensurePrimaryAndReplicate] Attempting sync ${operationType} to PRIMARY (${primaryName}) for key ${key}`);
|
|
const task = isDelete
|
|
? primaryClient.del(key)
|
|
: primaryClient.set(key, value, "EX", ttl);
|
|
|
|
replicationTasks.push(
|
|
task
|
|
.then(() => console.log(`[ensurePrimaryAndReplicate] SUCCESS: Synced ${operationType} key ${key} to PRIMARY (${primaryName})`))
|
|
.catch(err => console.error(`[ensurePrimaryAndReplicate] FAILED: Sync ${operationType} key ${key} to PRIMARY (${primaryName})`, err))
|
|
);
|
|
} else {
|
|
console.log(`[ensurePrimaryAndReplicate] SKIPPED: Sync ${operationType} to PRIMARY (${primaryName}) because it's not ready (Status: ${primaryStatus}).`);
|
|
}
|
|
} else if (sourceUrl === primaryUrl) {
|
|
console.log(`[ensurePrimaryAndReplicate] Source was PRIMARY (${primaryName}), no primary sync needed for ${operationType} on key ${key}.`);
|
|
} else {
|
|
console.log(`[ensurePrimaryAndReplicate] Primary client not available, cannot sync ${operationType} key ${key} to primary.`);
|
|
}
|
|
|
|
// 2. Replicate to other available replicas
|
|
console.log(`[ensurePrimaryAndReplicate] Checking ${replicaClients.size} replicas for ${operationType} on key ${key}`);
|
|
for (const [replicaUrl, replicaClient] of replicaClients.entries()) {
|
|
const replicaName = getRedisName(replicaUrl);
|
|
// Don't replicate back to the source or to the primary (if primary sync was attempted above)
|
|
if (replicaUrl !== sourceUrl && replicaUrl !== primaryUrl) {
|
|
const replicaStatus = replicaClient.status;
|
|
console.log(`[ensurePrimaryAndReplicate] Checking replica ${replicaName} status: ${replicaStatus}`);
|
|
if (replicaStatus === 'ready') {
|
|
console.log(`[ensurePrimaryAndReplicate] Attempting replication ${operationType} to REPLICA (${replicaName}) for key ${key}`);
|
|
const task = isDelete
|
|
? replicaClient.del(key)
|
|
: replicaClient.set(key, value, "EX", ttl);
|
|
replicationTasks.push(
|
|
task
|
|
.then(() => console.log(`[ensurePrimaryAndReplicate] SUCCESS: Replicated ${operationType} key ${key} to REPLICA (${replicaName})`))
|
|
.catch(err => console.error(`[ensurePrimaryAndReplicate] FAILED: Replicate ${operationType} key ${key} to REPLICA (${replicaName})`, err))
|
|
);
|
|
} else {
|
|
console.log(`[ensurePrimaryAndReplicate] SKIPPED: Replication ${operationType} to REPLICA (${replicaName}) because it's not ready (Status: ${replicaStatus}).`);
|
|
}
|
|
} else {
|
|
console.log(`[ensurePrimaryAndReplicate] SKIPPED: Replication ${operationType} to ${replicaName} (Source or Primary) for key ${key}.`);
|
|
}
|
|
}
|
|
|
|
// Wait for all sync/replication attempts to settle
|
|
if (replicationTasks.length > 0) {
|
|
console.log(`[ensurePrimaryAndReplicate] Waiting for ${replicationTasks.length} sync/replication tasks for ${operationType} on key ${key}...`);
|
|
const results = await Promise.allSettled(replicationTasks);
|
|
console.log(`[ensurePrimaryAndReplicate] Sync/replication tasks settled for ${operationType} on key ${key}. Results:`, results.map(r => r.status));
|
|
} else {
|
|
console.log(`[ensurePrimaryAndReplicate] No sync/replication tasks needed for ${operationType} on key ${key}.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Synchronize data from a replica to the primary
|
|
* Used when primary recovers after being down
|
|
*/
|
|
async function syncFromReplicaToPrimary(replicaUrl: string): Promise<void> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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");
|