diff --git a/CHANGELOG.md b/CHANGELOG.md index fb6b3ef37..67c7c36b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added the platform logo to the account selector in the create or update activity dialog +### Changed + +- Increased the timeout of the portfolio snapshot computation processor from 30 to 120 seconds + +### Fixed + +- Fixed the portfolio calculator to degrade gracefully instead of failing every portfolio and account endpoint when a snapshot computation job stalls + ## 3.42.0 - 2026-08-04 ### Changed diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-job-stalled.spec.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-job-stalled.spec.ts new file mode 100644 index 000000000..8dfde5542 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-job-stalled.spec.ts @@ -0,0 +1,122 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Job } from 'bull'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + it('should degrade gracefully instead of rejecting when the snapshot computation job stalls', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + jest.spyOn(portfolioSnapshotService, 'getJob').mockResolvedValue({ + finished: () => { + return Promise.reject( + new Error('job stalled more than allowable limit') + ); + } + } as Partial as Job); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Account Opening Fee', + symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' + }, + date: new Date('2021-09-01'), + feeInAssetProfileCurrency: 49, + feeInBaseCurrency: 49, + quantity: 0, + type: 'FEE', + unitPriceInAssetProfileCurrency: 0 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + await expect(portfolioCalculator.getSnapshot()).resolves.toMatchObject({ + hasErrors: true + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index cdab3fdf0..f800e16d8 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1198,25 +1198,51 @@ export abstract class PortfolioCalculator { } // Wait for computation - await this.portfolioSnapshotService.addJobToQueue({ - data: { - calculationType: this.getPerformanceCalculationType(), - filters: this.filters, - userCurrency: this.currency, - userId: this.userId - }, - name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, - opts: { - ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, - jobId, - priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH + try { + await this.portfolioSnapshotService.addJobToQueue({ + data: { + calculationType: this.getPerformanceCalculationType(), + filters: this.filters, + userCurrency: this.currency, + userId: this.userId + }, + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + opts: { + ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + jobId, + priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH + } + }); + + const job = await this.portfolioSnapshotService.getJob(jobId); + + if (job) { + await job.finished(); } - }); + } catch (error) { + // Degrade gracefully instead of failing every endpoint that + // depends on this snapshot, e.g. when the computation job stalls + this.logger.error( + `Portfolio snapshot computation for user '${this.userId}' failed: ${error}` + ); - const job = await this.portfolioSnapshotService.getJob(jobId); + this.snapshot = plainToClass(PortfolioSnapshot, { + activitiesCount: 0, + createdAt: new Date(), + currentValueInBaseCurrency: 0, + errors: [], + hasErrors: true, + historicalData: [], + positions: [], + totalCashInBaseCurrency: 0, + totalFeesWithCurrencyEffect: 0, + totalInterestWithCurrencyEffect: 0, + totalInvestment: 0, + totalInvestmentWithCurrencyEffect: 0, + totalLiabilitiesWithCurrencyEffect: 0 + }); - if (job) { - await job.finished(); + return; } await this.initialize(attempt + 1); diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index bb4ace0c5..bce1e7ca9 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -105,7 +105,7 @@ export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT = export const DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = - ms('30 seconds'); + ms('120 seconds'); export const DEFAULT_REDACTED_PATHS = [ 'accounts[*].balance',