Browse Source

Fix portfolio calculator to degrade gracefully on stalled snapshot job

A stalled portfolio snapshot computation job (job.finished() rejecting)
propagated out of the unguarded snapshotPromise and crashed every
portfolio/account endpoint for that user instead of just failing the
one calculation. Catch the failure and fall back to a snapshot with
hasErrors: true, and raise the default stall timeout from 30s to 120s
so fewer legitimately-large portfolios trigger it in the first place.

Closes #6914
pull/7475/head
Varun Jain 3 weeks ago
parent
commit
e290012d4b
  1. 8
      CHANGELOG.md
  2. 122
      apps/api/src/app/portfolio/calculator/portfolio-calculator-job-stalled.spec.ts
  3. 26
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  4. 2
      libs/common/src/lib/config.ts

8
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 - 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 ## 3.42.0 - 2026-08-04
### Changed ### Changed

122
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<Job> 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
});
});
});

26
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -1198,6 +1198,7 @@ export abstract class PortfolioCalculator {
} }
// Wait for computation // Wait for computation
try {
await this.portfolioSnapshotService.addJobToQueue({ await this.portfolioSnapshotService.addJobToQueue({
data: { data: {
calculationType: this.getPerformanceCalculationType(), calculationType: this.getPerformanceCalculationType(),
@ -1218,6 +1219,31 @@ export abstract class PortfolioCalculator {
if (job) { if (job) {
await job.finished(); 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}`
);
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
});
return;
}
await this.initialize(attempt + 1); await this.initialize(attempt + 1);
} }

2
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_GATHER_STATISTICS_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT =
ms('30 seconds'); ms('120 seconds');
export const DEFAULT_REDACTED_PATHS = [ export const DEFAULT_REDACTED_PATHS = [
'accounts[*].balance', 'accounts[*].balance',

Loading…
Cancel
Save