Browse Source

Merge e290012d4b into 3e6f5b61a1

pull/7475/merge
CoderVJain 20 hours ago
committed by GitHub
parent
commit
0bcd5d2245
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      CHANGELOG.md
  2. 122
      apps/api/src/app/portfolio/calculator/portfolio-calculator-job-stalled.spec.ts
  3. 58
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  4. 2
      libs/common/src/lib/config.ts

8
CHANGELOG.md

@ -214,6 +214,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Handled an exception in the country weightings parsing of the _Financial Modeling Prep_ service - Handled an exception in the country weightings parsing of the _Financial Modeling Prep_ service
### 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
});
});
});

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

@ -1198,25 +1198,51 @@ export abstract class PortfolioCalculator {
} }
// Wait for computation // Wait for computation
await this.portfolioSnapshotService.addJobToQueue({ try {
data: { await this.portfolioSnapshotService.addJobToQueue({
calculationType: this.getPerformanceCalculationType(), data: {
filters: this.filters, calculationType: this.getPerformanceCalculationType(),
userCurrency: this.currency, filters: this.filters,
userId: this.userId userCurrency: this.currency,
}, userId: this.userId
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, },
opts: { name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, opts: {
jobId, ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH 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) { return;
await job.finished();
} }
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