This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #394 from cosmik-network/feature/url-metadata-caching-20260105

Url Metadata Caching + Composition

+185 -15
+110
src/modules/cards/infrastructure/CachedMetadataService.ts
··· 1 + import Redis from 'ioredis'; 2 + import { IMetadataService } from '../domain/services/IMetadataService'; 3 + import { 4 + UrlMetadata, 5 + UrlMetadataProps, 6 + } from '../domain/value-objects/UrlMetadata'; 7 + import { URL } from '../domain/value-objects/URL'; 8 + import { Result, ok } from '../../../shared/core/Result'; 9 + 10 + export class CachedMetadataService implements IMetadataService { 11 + private readonly CACHE_KEY_PREFIX: string; 12 + private readonly CACHE_TTL_SECONDS: number; 13 + 14 + constructor( 15 + private readonly metadataService: IMetadataService, 16 + private readonly redis: Redis, 17 + private readonly serviceName: string, 18 + ttlSeconds: number = 3600, // 1 hour default 19 + ) { 20 + this.CACHE_KEY_PREFIX = `metadata:${serviceName}:`; 21 + this.CACHE_TTL_SECONDS = ttlSeconds; 22 + } 23 + 24 + async fetchMetadata(url: URL): Promise<Result<UrlMetadata>> { 25 + const cacheKey = this.getCacheKey(url.value); 26 + 27 + try { 28 + // Try cache first 29 + const cached = await this.redis.get(cacheKey); 30 + if (cached) { 31 + try { 32 + const metadataResult = UrlMetadata.create( 33 + JSON.parse(cached) as UrlMetadataProps, 34 + ); 35 + if (metadataResult.isErr()) { 36 + throw new Error( 37 + `Invalid cached metadata for ${url.value} from ${this.serviceName}: ${metadataResult.error.message}`, 38 + ); 39 + } 40 + const metadata = metadataResult.value; 41 + return ok(metadata); 42 + } catch (parseError) { 43 + // If JSON parsing fails, continue to fetch fresh data 44 + console.warn( 45 + `Failed to parse cached metadata for ${url.value} from ${this.serviceName}:`, 46 + parseError, 47 + ); 48 + } 49 + } 50 + 51 + // Cache miss or parse error - fetch from underlying service 52 + const result = await this.metadataService.fetchMetadata(url); 53 + 54 + if (result.isOk()) { 55 + // Cache the successful result 56 + try { 57 + await this.redis.setex( 58 + cacheKey, 59 + this.CACHE_TTL_SECONDS, 60 + JSON.stringify(result.value.props), 61 + ); 62 + } catch (cacheError) { 63 + // Log cache error but don't fail the request 64 + console.warn( 65 + `Failed to cache metadata for ${url.value} from ${this.serviceName}:`, 66 + cacheError, 67 + ); 68 + } 69 + } 70 + 71 + return result; 72 + } catch (redisError) { 73 + // If Redis is down, fall back to direct service call 74 + console.warn( 75 + `Redis error when fetching metadata for ${url.value} from ${this.serviceName}:`, 76 + redisError, 77 + ); 78 + return this.metadataService.fetchMetadata(url); 79 + } 80 + } 81 + 82 + async isAvailable(): Promise<boolean> { 83 + return this.metadataService.isAvailable(); 84 + } 85 + 86 + private getCacheKey(url: string): string { 87 + return `${this.CACHE_KEY_PREFIX}${url}`; 88 + } 89 + 90 + /** 91 + * Invalidate cached metadata for a specific URL 92 + */ 93 + async invalidateMetadata(url: string): Promise<void> { 94 + try { 95 + await this.redis.del(this.getCacheKey(url)); 96 + } catch (error) { 97 + console.warn( 98 + `Failed to invalidate metadata cache for ${url} from ${this.serviceName}:`, 99 + error, 100 + ); 101 + } 102 + } 103 + 104 + /** 105 + * Warm the cache by pre-fetching metadata for a URL 106 + */ 107 + async warmCache(url: URL): Promise<void> { 108 + await this.fetchMetadata(url); 109 + } 110 + }
+33 -2
src/modules/cards/infrastructure/CompositeMetadataService.ts
··· 79 79 return ok(citoidSuccess); 80 80 } 81 81 82 - // Both succeeded, apply selection logic 82 + // Both succeeded, apply selection logic and merge missing fields 83 83 if (iframelySuccess && citoidSuccess) { 84 84 const selectedMetadata = this.selectBestMetadata( 85 85 iframelySuccess, 86 86 citoidSuccess, 87 87 ); 88 - return ok(selectedMetadata); 88 + const mergedMetadata = this.mergeMetadata( 89 + selectedMetadata, 90 + selectedMetadata === iframelySuccess ? citoidSuccess : iframelySuccess, 91 + ); 92 + return ok(mergedMetadata); 89 93 } 90 94 91 95 // This should never happen, but just in case ··· 153 157 */ 154 158 public async fetchFromCitoid(url: URL): Promise<Result<UrlMetadata>> { 155 159 return this.citoidService.fetchMetadata(url); 160 + } 161 + 162 + /** 163 + * Merge metadata by taking missing fields from the fallback metadata 164 + */ 165 + private mergeMetadata( 166 + primary: UrlMetadata, 167 + fallback: UrlMetadata, 168 + ): UrlMetadata { 169 + // Create merged props by taking primary values first, then fallback for missing fields 170 + const mergedProps = { 171 + url: primary.url, // URL should always be the same 172 + title: primary.title || fallback.title, 173 + description: primary.description || fallback.description, 174 + author: primary.author || fallback.author, 175 + publishedDate: primary.publishedDate || fallback.publishedDate, 176 + siteName: primary.siteName || fallback.siteName, 177 + imageUrl: primary.imageUrl || fallback.imageUrl, 178 + type: primary.type || fallback.type, 179 + retrievedAt: primary.retrievedAt || fallback.retrievedAt, 180 + doi: primary.doi || fallback.doi, 181 + isbn: primary.isbn || fallback.isbn, 182 + }; 183 + 184 + // Create new UrlMetadata with merged props 185 + // We know this will succeed since both primary and fallback are valid 186 + return UrlMetadata.create(mergedProps).unwrap(); 156 187 } 157 188 }
+33 -3
src/shared/infrastructure/http/factories/ServiceFactory.ts
··· 10 10 import { IFramelyMetadataService } from '../../../../modules/cards/infrastructure/IFramelyMetadataService'; 11 11 import { CitoidMetadataService } from '../../../../modules/cards/infrastructure/CitoidMetadataService'; 12 12 import { CompositeMetadataService } from '../../../../modules/cards/infrastructure/CompositeMetadataService'; 13 + import { CachedMetadataService } from '../../../../modules/cards/infrastructure/CachedMetadataService'; 13 14 import { BlueskyProfileService } from '../../../../modules/atproto/infrastructure/services/BlueskyProfileService'; 14 15 import { CachedBlueskyProfileService } from '../../../../modules/atproto/infrastructure/services/CachedBlueskyProfileService'; 15 16 import { ATProtoCollectionPublisher } from '../../../../modules/atproto/infrastructure/publishers/ATProtoCollectionPublisher'; ··· 259 260 : new ATProtoAgentService(nodeOauthClient, appPasswordSessionService); 260 261 261 262 // Create individual metadata services 262 - const iframelyService = new IFramelyMetadataService( 263 + const baseIframelyService = new IFramelyMetadataService( 263 264 configService.getIFramelyApiKey(), 264 265 ); 265 - const citoidService = new CitoidMetadataService(); 266 + const baseCitoidService = new CitoidMetadataService(); 267 + 268 + // Apply caching conditionally (similar to profile service) 269 + const useMockPersistence = configService.shouldUseMockPersistence(); 270 + 271 + let iframelyService: IMetadataService; 272 + let citoidService: IMetadataService; 273 + 274 + if (useMockPersistence) { 275 + // No caching for mock persistence 276 + iframelyService = baseIframelyService; 277 + citoidService = baseCitoidService; 278 + } else { 279 + // Create Redis connection for caching 280 + const redisConfig = configService.getRedisConfig(); 281 + const redis = RedisFactory.createConnection(redisConfig); 282 + 283 + // Wrap services with caching - different TTLs for different services 284 + iframelyService = new CachedMetadataService( 285 + baseIframelyService, 286 + redis, 287 + 'iframely', 288 + 3600 * 24 * 7, // 7 day TTL 289 + ); 290 + citoidService = new CachedMetadataService( 291 + baseCitoidService, 292 + redis, 293 + 'citoid', 294 + 3600 * 24 * 7, // 7 day TTL 295 + ); 296 + } 266 297 267 298 // Create composite metadata service 268 299 const metadataService = new CompositeMetadataService( ··· 274 305 const baseProfileService = new BlueskyProfileService(atProtoAgentService); 275 306 276 307 let profileService: IProfileService; 277 - const useMockPersistence = configService.shouldUseMockPersistence(); 278 308 279 309 // caching requires persistence 280 310 if (useMockPersistence) {
+2 -2
src/webapp/components/navigation/appLayout/AppLayout.tsx
··· 29 29 navbar={{ 30 30 width: 300, 31 31 breakpoint: 'xs', 32 - collapsed: { mobile: !mobileOpened, desktop: !desktopOpened }, 32 + collapsed: { mobile: !mobileOpened, desktop: !desktopOpened }, 33 33 }} 34 34 aside={{ 35 35 width: asideWidth, 36 36 breakpoint: 'xl', 37 - collapsed: { mobile: true }, 37 + collapsed: { mobile: true }, 38 38 }} 39 39 footer={{ 40 40 height: isMobile ? 85 : 0,
+3 -3
src/webapp/features/cards/components/UrlCardDebugView/UrlCardDebugView.tsx
··· 10 10 11 11 export default function UrlCardDebugView(props: Props) { 12 12 return ( 13 - <CodeHighlightTabs 13 + <CodeHighlightTabs 14 14 code={[ 15 15 { 16 16 fileName: 'Content', 17 17 code: JSON.stringify(props.cardContent, null, 2), 18 - language: 'json', 18 + language: 'json', 19 19 }, 20 20 { 21 21 fileName: 'Author', 22 22 code: JSON.stringify(props.cardAuthor, null, 2), 23 - language: 'json', 23 + language: 'json', 24 24 }, 25 25 ]} 26 26 radius="md"
+4 -4
src/webapp/features/profile/components/profileHoverCard/ProfileHoverCard.tsx
··· 26 26 <HoverCardDropdown p={'xs'}> 27 27 <Stack gap={'sm'}> 28 28 <Group gap={'xs'}> 29 - <Avatar src={profile.avatarUrl?.replace( 30 - 'avatar', 31 - 'avatar_thumbnail', 32 - )} size={'lg'} /> 29 + <Avatar 30 + src={profile.avatarUrl?.replace('avatar', 'avatar_thumbnail')} 31 + size={'lg'} 32 + /> 33 33 <Stack gap={0}> 34 34 <Text fw={600} c={'bright'}> 35 35 {profile.name}
-1
src/webapp/features/profile/lib/dal.ts
··· 19 19 20 20 return response; 21 21 }); 22 -