diff --git a/CHANGELOG.md b/CHANGELOG.md index 477048f59..d5c6481e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the style of the empty state in the _Fear & Greed Index_ component - Improved the style of the type filter in the activities table component (experimental) +- Improved the performance of the property service by caching the properties in memory - Improved the language localization for German (`de`) ## 3.37.0 - 2026-07-30 diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 26a4e06f4..4c608e0fd 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -107,8 +107,11 @@ export class AdminService { await this.marketDataService.deleteMany({ dataSource, symbol }); const currency = getCurrencyFromSymbol(symbol); - const customCurrencies = - await this.propertyService.getByKey(PROPERTY_CURRENCIES); + + const customCurrencies = await this.propertyService.getByKey( + PROPERTY_CURRENCIES, + { skipCache: true } + ); if (customCurrencies.includes(currency)) { const updatedCustomCurrencies = customCurrencies.filter( diff --git a/apps/api/src/app/health/health.service.ts b/apps/api/src/app/health/health.service.ts index f08f33a1e..42a0be61b 100644 --- a/apps/api/src/app/health/health.service.ts +++ b/apps/api/src/app/health/health.service.ts @@ -26,7 +26,9 @@ export class HealthService { public async isDatabaseHealthy() { try { - await this.propertyService.getByKey(PROPERTY_CURRENCIES); + await this.propertyService.getByKey(PROPERTY_CURRENCIES, { + skipCache: true + }); return true; } catch { diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index 4018e4753..a70fe8791 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -54,7 +54,9 @@ export class SubscriptionController { } let coupons = - (await this.propertyService.getByKey(PROPERTY_COUPONS)) ?? []; + (await this.propertyService.getByKey(PROPERTY_COUPONS, { + skipCache: true + })) ?? []; const coupon = coupons.find((currentCoupon) => { return currentCoupon.code === couponCode; diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 17e729f9f..993e0f0aa 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -159,7 +159,8 @@ export class BenchmarkService { let benchmarks = (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS + PROPERTY_BENCHMARKS, + { skipCache: true } )) ?? []; benchmarks.push({ symbolProfileId: assetProfile.id }); @@ -196,7 +197,8 @@ export class BenchmarkService { let benchmarks = (await this.propertyService.getByKey( - PROPERTY_BENCHMARKS + PROPERTY_BENCHMARKS, + { skipCache: true } )) ?? []; benchmarks = benchmarks.filter(({ symbolProfileId }) => { diff --git a/apps/api/src/services/property/property.service.ts b/apps/api/src/services/property/property.service.ts index 80643482f..6d8130bbc 100644 --- a/apps/api/src/services/property/property.service.ts +++ b/apps/api/src/services/property/property.service.ts @@ -6,27 +6,39 @@ import { import { PropertyKey } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { Property } from '@prisma/client'; +import { addMilliseconds, isBefore } from 'date-fns'; +import ms from 'ms'; import { PropertyValue } from './interfaces/interfaces'; @Injectable() export class PropertyService { + private static readonly CACHE_TTL = ms('1 minute'); + + private cachedProperties: Promise; + private cachedPropertiesExpiresAt: Date; + public constructor(private readonly prismaService: PrismaService) {} public async delete({ key }: { key: PropertyKey }) { - return this.prismaService.property.delete({ + const property = await this.prismaService.property.delete({ where: { key } }); + + this.invalidateCache(); + + return property; } - public async get() { + public async get({ skipCache = false } = {}) { const response: { [key: string]: PropertyValue; } = { [PROPERTY_CURRENCIES]: [] }; - const properties = await this.prismaService.property.findMany(); + const properties = await this.getProperties({ skipCache }); for (const property of properties) { let value = property.value; @@ -41,8 +53,11 @@ export class PropertyService { return response; } - public async getByKey(aKey: PropertyKey) { - const properties = await this.get(); + public async getByKey( + aKey: PropertyKey, + { skipCache = false } = {} + ) { + const properties = await this.get({ skipCache }); return properties[aKey] as TValue; } @@ -53,10 +68,53 @@ export class PropertyService { } public async put({ key, value }: { key: PropertyKey; value: string }) { - return this.prismaService.property.upsert({ + const property = await this.prismaService.property.upsert({ create: { key, value }, update: { value }, where: { key } }); + + this.invalidateCache(); + + return property; + } + + /** + * Returns the properties from the in-memory cache, falling back to the + * database. Callers which write back a modified property must set + * skipCache to avoid basing the write on a stale read. + */ + private async getProperties({ skipCache = false } = {}) { + if (skipCache) { + return this.prismaService.property.findMany(); + } + + if ( + this.cachedProperties && + isBefore(new Date(), this.cachedPropertiesExpiresAt) + ) { + return this.cachedProperties; + } + + const properties = this.prismaService.property.findMany().catch((error) => { + if (this.cachedProperties === properties) { + this.invalidateCache(); + } + + throw error; + }); + + this.cachedProperties = properties; + this.cachedPropertiesExpiresAt = addMilliseconds( + new Date(), + PropertyService.CACHE_TTL + ); + + return this.cachedProperties; + } + + private invalidateCache() { + this.cachedProperties = undefined; + this.cachedPropertiesExpiresAt = undefined; } }