Merge branch 'issue264' into issue-260
This commit is contained in:
@@ -36,6 +36,38 @@ context('liveness tests: ', function () {
|
||||
})
|
||||
})
|
||||
|
||||
// Multiple concurrent DC instances can cause the startupservice request
|
||||
// to time out even though a retry succeeds. Fail only the first
|
||||
// startupservice call (any later ones, e.g. from other tests/reloads,
|
||||
// pass through untouched) and confirm the app still reaches the nav tree
|
||||
// instead of showing the startup error modal.
|
||||
it('2 | Recovers from a failed startupservice request via the single retry', () => {
|
||||
let startupServiceCallCount = 0
|
||||
|
||||
cy.intercept('POST', '**/SASjsApi/stp/execute*', (req) => {
|
||||
const isStartupService = JSON.stringify(req.body || '').includes(
|
||||
'startupservice'
|
||||
)
|
||||
|
||||
if (isStartupService) startupServiceCallCount++
|
||||
|
||||
if (isStartupService && startupServiceCallCount === 1) {
|
||||
req.reply({ statusCode: 503, body: 'Service unavailable' })
|
||||
} else {
|
||||
req.continue()
|
||||
}
|
||||
}).as('stpExecute')
|
||||
|
||||
visitPage('home')
|
||||
|
||||
cy.get('.nav-tree clr-tree > clr-tree-node', {
|
||||
// Longer than the retry's own 0-3s pause on top of the normal timeout.
|
||||
timeout: longerCommandTimeout + 3000
|
||||
}).should('exist')
|
||||
|
||||
cy.get('.abortMsg').should('not.exist')
|
||||
})
|
||||
|
||||
/**
|
||||
* Thist part will be needed if we add more tests in future
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { EventEmitter } from '@angular/core'
|
||||
import { BehaviorSubject, Subject } from 'rxjs'
|
||||
import { AppService } from './app.service'
|
||||
|
||||
/**
|
||||
* AppService's constructor subscribes to several emitters/subjects (see
|
||||
* subscribe() and the persistSelectedTheme check) and startUpData() touches
|
||||
* a handful of methods on each collaborator — these stubs cover exactly
|
||||
* that surface, not the full real services (no TestBed/DI needed, same
|
||||
* plain-instantiation-with-stubs precedent as va-filter.service.spec.ts).
|
||||
*/
|
||||
const buildDeps = () => {
|
||||
const licenceService: any = {
|
||||
isAppActivated: new BehaviorSubject<boolean | null>(null),
|
||||
activation: jasmine.createSpy('activation').and.resolveTo(undefined)
|
||||
}
|
||||
const eventService: any = {
|
||||
showInfoModal: jasmine.createSpy('showInfoModal'),
|
||||
startupDataLoaded: jasmine.createSpy('startupDataLoaded'),
|
||||
toggleDarkMode: jasmine.createSpy('toggleDarkMode')
|
||||
}
|
||||
const sasService: any = {
|
||||
loadStartupServiceEmitter: new EventEmitter<any>(),
|
||||
requestSiteIdEmitter: new EventEmitter<string>(),
|
||||
request: jasmine.createSpy('request')
|
||||
}
|
||||
const loggerService: any = {
|
||||
log: jasmine.createSpy('log')
|
||||
}
|
||||
const appSettingsService: any = {
|
||||
settings: new BehaviorSubject({ persistSelectedTheme: false } as any)
|
||||
}
|
||||
const router: any = {
|
||||
events: new Subject(),
|
||||
url: '',
|
||||
navigateByUrl: jasmine.createSpy('navigateByUrl')
|
||||
}
|
||||
const appStoreService: any = {
|
||||
getDcAdapterSettings: () => undefined
|
||||
}
|
||||
|
||||
return {
|
||||
licenceService,
|
||||
eventService,
|
||||
sasService,
|
||||
loggerService,
|
||||
appSettingsService,
|
||||
router,
|
||||
appStoreService
|
||||
}
|
||||
}
|
||||
|
||||
const buildAppService = (deps: ReturnType<typeof buildDeps>) =>
|
||||
new AppService(
|
||||
deps.licenceService,
|
||||
deps.eventService,
|
||||
deps.sasService,
|
||||
deps.loggerService,
|
||||
deps.appSettingsService,
|
||||
deps.router,
|
||||
deps.appStoreService
|
||||
)
|
||||
|
||||
// Minimal valid startupservice payload — just enough to pass startUpData()'s
|
||||
// missing-props check (Globvars/Sasdatasets/Saslibs/XLMaps).
|
||||
const validStartupResponse = () => ({
|
||||
adapterResponse: {
|
||||
SYSSITE: 'SITE1',
|
||||
globvars: [{ ISADMIN: false, DC_ADMIN_GROUP: '', DCLIB: 'DCLIB' }],
|
||||
sasdatasets: [],
|
||||
saslibs: {},
|
||||
xlmaps: []
|
||||
}
|
||||
})
|
||||
|
||||
describe('AppService - startup retry', () => {
|
||||
it('retries once and succeeds without showing the error modal when only the first attempt rejects', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.returnValues(
|
||||
Promise.reject('timeout'),
|
||||
Promise.resolve(validStartupResponse())
|
||||
)
|
||||
|
||||
const appService = buildAppService(deps)
|
||||
// Keep the retry pause instant in tests — see STARTUP_RETRY_PLAN.md.
|
||||
;(appService as any).retryOptions = {
|
||||
wait: () => Promise.resolve(),
|
||||
random: () => 0
|
||||
}
|
||||
|
||||
await appService.startUpData()
|
||||
|
||||
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
|
||||
expect(deps.eventService.showInfoModal).not.toHaveBeenCalled()
|
||||
expect(deps.eventService.startupDataLoaded).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the error modal once (not twice) when both attempts reject', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.returnValues(
|
||||
Promise.reject('timeout-1'),
|
||||
Promise.reject('timeout-2')
|
||||
)
|
||||
|
||||
const appService = buildAppService(deps)
|
||||
;(appService as any).retryOptions = {
|
||||
wait: () => Promise.resolve(),
|
||||
random: () => 0
|
||||
}
|
||||
|
||||
await appService.startUpData()
|
||||
|
||||
expect(deps.sasService.request).toHaveBeenCalledTimes(2)
|
||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
||||
expect(deps.licenceService.isAppActivated.value).toBeFalse()
|
||||
})
|
||||
|
||||
it('does not retry a response that arrives but is missing required properties', async () => {
|
||||
const deps = buildDeps()
|
||||
deps.sasService.request.and.resolveTo({
|
||||
adapterResponse: {
|
||||
SYSSITE: 'SITE1',
|
||||
// globvars deliberately omitted
|
||||
sasdatasets: [],
|
||||
saslibs: {},
|
||||
xlmaps: []
|
||||
}
|
||||
})
|
||||
|
||||
const appService = buildAppService(deps)
|
||||
;(appService as any).retryOptions = {
|
||||
wait: () => Promise.resolve(),
|
||||
random: () => 0
|
||||
}
|
||||
|
||||
await appService.startUpData()
|
||||
|
||||
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
|
||||
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -11,11 +11,15 @@ import { AppSettingsService } from './app-settings.service'
|
||||
import { AppThemes } from '../models/AppSettings'
|
||||
import { RequestWrapperResponse } from '../models/request-wrapper/RequestWrapperResponse'
|
||||
import { AppStoreService } from './app-store.service'
|
||||
import { retryOnce, RetryOnceOptions } from '../shared/utils/retry-once'
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
public syssite = new BehaviorSubject<string[] | null>(null)
|
||||
private environmentInfo: EnvironmentInfo = {}
|
||||
// Overridable in tests to keep the retry pause instant — see
|
||||
// retry-once.spec.ts and app.service.spec.ts.
|
||||
private retryOptions: RetryOnceOptions = {}
|
||||
|
||||
constructor(
|
||||
private licenceService: LicenceService,
|
||||
@@ -82,8 +86,13 @@ export class AppService {
|
||||
public async startUpData() {
|
||||
let startupServiceError = false
|
||||
|
||||
await this.sasService
|
||||
.request('public/startupservice', null)
|
||||
// Retry once on failure — multiple concurrent DC instances (e.g. VA
|
||||
// multi-page reports) can cause this request to time out even though a
|
||||
// second attempt succeeds. See STARTUP_RETRY_PLAN.md.
|
||||
await retryOnce(
|
||||
() => this.sasService.request('public/startupservice', null),
|
||||
this.retryOptions
|
||||
)
|
||||
.then(async (res: RequestWrapperResponse) => {
|
||||
this.syssite.next([res.adapterResponse.SYSSITE])
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { retryOnce } from './retry-once'
|
||||
|
||||
describe('retryOnce', () => {
|
||||
it("resolves with the first attempt's value and never waits when it succeeds", async () => {
|
||||
const requestFn = jasmine.createSpy('requestFn').and.resolveTo('ok')
|
||||
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
|
||||
|
||||
const result = await retryOnce(requestFn, { wait })
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(requestFn).toHaveBeenCalledTimes(1)
|
||||
expect(wait).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries exactly once after a rejection and resolves with the retry value', async () => {
|
||||
const requestFn = jasmine
|
||||
.createSpy('requestFn')
|
||||
.and.returnValues(Promise.reject('first-error'), Promise.resolve('ok'))
|
||||
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
|
||||
|
||||
const result = await retryOnce(requestFn, { wait })
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(requestFn).toHaveBeenCalledTimes(2)
|
||||
expect(wait).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('derives the pre-retry pause from random within [0, maxDelayMs)', async () => {
|
||||
const requestFn = jasmine
|
||||
.createSpy('requestFn')
|
||||
.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
|
||||
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
|
||||
|
||||
await retryOnce(requestFn, { wait, random: () => 0.5 })
|
||||
expect(wait).toHaveBeenCalledWith(1500)
|
||||
|
||||
wait.calls.reset()
|
||||
requestFn.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
|
||||
await retryOnce(requestFn, { wait, random: () => 0 })
|
||||
expect(wait).toHaveBeenCalledWith(0)
|
||||
|
||||
wait.calls.reset()
|
||||
requestFn.and.returnValues(Promise.reject('err'), Promise.resolve('ok'))
|
||||
await retryOnce(requestFn, { wait, random: () => 0.999999 })
|
||||
expect(wait).toHaveBeenCalledWith(jasmine.any(Number))
|
||||
expect(wait.calls.mostRecent().args[0]).toBeLessThan(3000)
|
||||
})
|
||||
|
||||
it('rejects with the second error when both attempts fail, never trying a third time', async () => {
|
||||
const requestFn = jasmine
|
||||
.createSpy('requestFn')
|
||||
.and.returnValues(
|
||||
Promise.reject('first-error'),
|
||||
Promise.reject('second-error')
|
||||
)
|
||||
const wait = jasmine.createSpy('wait').and.resolveTo(undefined)
|
||||
|
||||
await expectAsync(retryOnce(requestFn, { wait })).toBeRejectedWith(
|
||||
'second-error'
|
||||
)
|
||||
expect(requestFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface RetryOnceOptions {
|
||||
/** Upper bound of the random pre-retry delay, in ms. Default 3000. */
|
||||
maxDelayMs?: number
|
||||
/** Injectable for tests. Default Math.random. */
|
||||
random?: () => number
|
||||
/** Injectable for tests. Default setTimeout-based wait. */
|
||||
wait?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
const defaultWait = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Runs requestFn; if it rejects, waits a random 0..maxDelayMs and tries
|
||||
* exactly once more. A second rejection propagates as-is. Multiple
|
||||
* concurrent DC instances (e.g. VA multi-page reports) can cause the
|
||||
* startup request to time out even though a second attempt succeeds.
|
||||
*/
|
||||
export const retryOnce = async <T>(
|
||||
requestFn: () => Promise<T>,
|
||||
options?: RetryOnceOptions
|
||||
): Promise<T> => {
|
||||
const maxDelayMs = options?.maxDelayMs ?? 3000
|
||||
const random = options?.random ?? Math.random
|
||||
const wait = options?.wait ?? defaultWait
|
||||
|
||||
try {
|
||||
return await requestFn()
|
||||
} catch {
|
||||
await wait(random() * maxDelayMs)
|
||||
return requestFn()
|
||||
}
|
||||
}
|
||||
@@ -626,6 +626,7 @@ create table dqdata as
|
||||
%put &=source;
|
||||
%put &=lib;
|
||||
%dc_assignlib(READ,&lib)
|
||||
%dc_casload(&lib..&ds)
|
||||
proc sql;
|
||||
create table dqdata&x as
|
||||
select distinct "&&base_col&x" as base_col length=32
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
%end;
|
||||
|
||||
proc casutil;
|
||||
load casdata="&ds"
|
||||
load casdata="&ds..sashdat"
|
||||
incaslib="&caslib"
|
||||
casout="&ds"
|
||||
outcaslib="&caslib"
|
||||
|
||||
Reference in New Issue
Block a user