From 7d053caf805a25c1b02bba19ebdfc32c05630006 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:42:01 +0200 Subject: [PATCH] Bugfix/portfolio snapshot cache write and initialization retry limit (#7517) * Fix portfolio snapshot cache write and initialization retry limit * Update changelog --- CHANGELOG.md | 2 ++ apps/api/src/app/app.module.ts | 10 ++++++- .../calculator/portfolio-calculator.ts | 18 ++++++++++-- ...tfolio-calculator-baln-buy-and-buy.spec.ts | 3 ++ ...aln-buy-and-sell-in-two-activities.spec.ts | 3 ++ ...folio-calculator-baln-buy-and-sell.spec.ts | 3 ++ .../portfolio-calculator-baln-buy.spec.ts | 3 ++ ...ulator-btceur-in-base-currency-eur.spec.ts | 3 ++ .../roai/portfolio-calculator-btceur.spec.ts | 3 ++ ...ator-btcusd-buy-and-sell-partially.spec.ts | 3 ++ .../portfolio-calculator-btcusd-short.spec.ts | 3 ++ .../roai/portfolio-calculator-btcusd.spec.ts | 3 ++ .../roai/portfolio-calculator-cash.spec.ts | 3 ++ .../roai/portfolio-calculator-fee.spec.ts | 3 ++ .../portfolio-calculator-googl-buy.spec.ts | 3 ++ ...jnug-buy-and-sell-and-buy-and-sell.spec.ts | 3 ++ .../portfolio-calculator-liability.spec.ts | 3 ++ ...folio-calculator-msft-buy-and-sell.spec.ts | 3 ++ ...-calculator-msft-buy-with-dividend.spec.ts | 3 ++ ...portfolio-calculator-no-activities.spec.ts | 3 ++ ...ulator-novn-buy-and-sell-partially.spec.ts | 3 ++ ...folio-calculator-novn-buy-and-sell.spec.ts | 3 ++ .../portfolio-calculator-valuable.spec.ts | 3 ++ .../portfolio-snapshot-computation.error.ts | 7 +++++ .../redis-cache/redis-cache.service.mock.ts | 3 ++ ...o-snapshot-computation-exception.filter.ts | 26 +++++++++++++++++ .../portfolio-snapshot.processor.ts | 2 +- .../portfolio-snapshot.service.mock.ts | 29 ++++++++++++++----- 28 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts create mode 100644 apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a916f8643..d6ef5e2f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed the loading state in the user detail dialog of the admin control panel’s users section +- Fixed a race condition where the portfolio snapshot computation was completed before its result had been cached, causing a redundant recomputation +- Fixed an endless loop in the portfolio snapshot computation if the computed result could not be read from the cache ## 3.40.0 - 2026-08-02 diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index bd76ef49b..ddda044a7 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,4 +1,5 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; +import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter'; import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; @@ -24,6 +25,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis' import { BullModule } from '@nestjs/bull'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; @@ -184,7 +186,13 @@ import { UserModule } from './user/user.module'; UserModule, WatchlistModule ], - providers: [I18nService] + providers: [ + I18nService, + { + provide: APP_FILTER, + useClass: PortfolioSnapshotComputationExceptionFilter + } + ] }) export class AppModule implements NestModule { public configure(consumer: MiddlewareConsumer) { diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 38f942156..cdab3fdf0 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1,4 +1,5 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; @@ -65,6 +66,8 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash'; export abstract class PortfolioCalculator { protected static readonly ENABLE_LOGGING = false; + private static readonly MAX_INITIALIZATION_ATTEMPTS = 3; + protected readonly logger = new Logger(PortfolioCalculator.name); protected accountBalanceItems: HistoricalDataItem[]; @@ -174,6 +177,11 @@ export abstract class PortfolioCalculator { this.computeTransactionPoints(); this.snapshotPromise = this.initialize(); + + // Mark the rejection as handled to prevent an unhandled promise rejection + // in case the snapshot promise is never awaited. Consumers awaiting it + // still receive the error. + this.snapshotPromise.catch(() => undefined); } protected abstract calculateOverallPerformance( @@ -1124,7 +1132,7 @@ export abstract class PortfolioCalculator { } @LogPerformance - private async initialize() { + private async initialize(attempt = 1) { const startTimeTotal = performance.now(); let cachedPortfolioSnapshot: PortfolioSnapshot; @@ -1183,6 +1191,12 @@ export abstract class PortfolioCalculator { }); } } else { + if (attempt > PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS) { + throw new PortfolioSnapshotComputationError( + `Portfolio snapshot of user '${this.userId}' could not be computed after ${PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS} attempts` + ); + } + // Wait for computation await this.portfolioSnapshotService.addJobToQueue({ data: { @@ -1205,7 +1219,7 @@ export abstract class PortfolioCalculator { await job.finished(); } - await this.initialize(); + await this.initialize(attempt + 1); } } } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts index fe23af04b..a6bedc55d 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts index 061aaf817..dc22cdbab 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts index c8e0af46d..9d55a79dd 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts index 4922382a9..a2d576361 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts index 21a8d2056..1143e3bd2 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts @@ -76,6 +76,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts index 4f5da58b8..e5b0d69d6 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index f20506b06..ea1df4203 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts index 79c6979ef..93d91c500 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts @@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index eb5571feb..1fa2d1264 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index aaa2f63cf..3b09bfd26 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -77,6 +77,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); exchangeRateDataService = new ExchangeRateDataService( diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts index 4e413c0c5..000cc5935 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts index 984dc1154..451973a9f 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts @@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts index 962cfe2d8..2cc87934a 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts @@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts index 94654af61..68572c63e 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts index b1f030aae..53236f007 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts @@ -52,6 +52,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); exchangeRateDataService = new ExchangeRateDataService( diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 91c095623..f6598f22b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts index ff5dc93f9..fb7a43477 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts @@ -49,6 +49,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 8ce62db59..8c3858dcd 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index 10cc2da01..364d173e1 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => { }); beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts index 226eaa3d8..ce5f90f5c 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts @@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + configurationService = new ConfigurationService(); currentRateService = new CurrentRateService(null, null, null, null); diff --git a/apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts b/apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts new file mode 100644 index 000000000..074ac0bae --- /dev/null +++ b/apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts @@ -0,0 +1,7 @@ +export class PortfolioSnapshotComputationError extends Error { + public constructor(message: string) { + super(message); + + this.name = 'PortfolioSnapshotComputationError'; + } +} diff --git a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts index feb669ab0..2a3c1cc7a 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts @@ -18,6 +18,9 @@ export const RedisCacheServiceMock = { return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`; }, + reset: () => { + RedisCacheServiceMock.cache.clear(); + }, set: (key: string, value: string): Promise => { RedisCacheServiceMock.cache.set(key, value); diff --git a/apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts b/apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts new file mode 100644 index 000000000..05471c3f6 --- /dev/null +++ b/apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts @@ -0,0 +1,26 @@ +import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error'; + +import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common'; +import { Response } from 'express'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; + +@Catch(PortfolioSnapshotComputationError) +export class PortfolioSnapshotComputationExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger( + PortfolioSnapshotComputationExceptionFilter.name + ); + + public catch( + exception: PortfolioSnapshotComputationError, + host: ArgumentsHost + ) { + this.logger.error(exception.message); + + const response = host.switchToHttp().getResponse(); + + response.status(StatusCodes.SERVICE_UNAVAILABLE).json({ + message: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), + statusCode: StatusCodes.SERVICE_UNAVAILABLE + }); + } +} diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts index cf94a9d2b..2ade39a8a 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -87,7 +87,7 @@ export class PortfolioSnapshotProcessor { : 0 ); - this.redisCacheService.set( + await this.redisCacheService.set( this.redisCacheService.getPortfolioSnapshotKey({ filters: job.data.filters, userId: job.data.userId diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts index 7eb09d966..fddbd01ab 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts @@ -1,32 +1,47 @@ +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; + import type { Job, JobId, JobOptions } from 'bull'; +import ms from 'ms'; import { setTimeout } from 'timers/promises'; import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; export const PortfolioSnapshotServiceMock = { - addJobToQueue({ + addJobToQueue: ({ opts }: { data: PortfolioSnapshotQueueJob; name: string; opts?: JobOptions; - }): Promise { + }): Promise => { const mockJob: Partial = { finished: async () => { await setTimeout(100); - return Promise.resolve(); + // Mimic the processor which caches the computed portfolio snapshot + // under the job id + await RedisCacheServiceMock.set( + opts?.jobId as string, + JSON.stringify({ + expiration: Date.now() + ms('1 minute'), + portfolioSnapshot: {} + } as unknown as PortfolioSnapshotValue) + ); } }; - this.jobsStore.set(opts?.jobId, mockJob); + PortfolioSnapshotServiceMock.jobsStore.set(opts?.jobId, mockJob); return Promise.resolve(mockJob as Job); }, - getJob(jobId: JobId): Promise { - const job = this.jobsStore.get(jobId); + getJob: (jobId: JobId): Promise => { + const job = PortfolioSnapshotServiceMock.jobsStore.get(jobId); return Promise.resolve(job as Job); }, - jobsStore: new Map>() + jobsStore: new Map>(), + reset: () => { + PortfolioSnapshotServiceMock.jobsStore.clear(); + } };