1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-01-04 03:00:05 +00:00

chore(streamlog): optimise polling mechanism

This commit is contained in:
Krishna Acondy
2021-07-20 09:25:39 +01:00
parent 1594f0c7db
commit 5c8d311ae8
14 changed files with 624 additions and 230 deletions

188
package-lock.json generated
View File

@@ -7,7 +7,7 @@
"name": "@sasjs/adapter", "name": "@sasjs/adapter",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@sasjs/utils": "^2.24.0", "@sasjs/utils": "^2.25.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"axios-cookiejar-support": "^1.0.1", "axios-cookiejar-support": "^1.0.1",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@@ -1188,8 +1188,9 @@
} }
}, },
"node_modules/@sasjs/utils": { "node_modules/@sasjs/utils": {
"version": "2.24.0", "version": "2.25.1",
"license": "ISC", "resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-2.25.1.tgz",
"integrity": "sha512-lwkPE+QsB81b8/1M8X2zLdhpuiA8pIjgOwJH57zhcsliuDnNs4uijSYu40aYSc8tH98jtSuqWMjfGq8CT9o1Dw==",
"dependencies": { "dependencies": {
"@types/prompts": "^2.0.13", "@types/prompts": "^2.0.13",
"chalk": "^4.1.1", "chalk": "^4.1.1",
@@ -7932,7 +7933,182 @@
"treeverse", "treeverse",
"validate-npm-package-name", "validate-npm-package-name",
"which", "which",
"write-file-atomic" "write-file-atomic",
"@npmcli/disparity-colors",
"@npmcli/git",
"@npmcli/installed-package-contents",
"@npmcli/map-workspaces",
"@npmcli/metavuln-calculator",
"@npmcli/move-file",
"@npmcli/name-from-folder",
"@npmcli/node-gyp",
"@npmcli/promise-spawn",
"@tootallnate/once",
"agent-base",
"agentkeepalive",
"aggregate-error",
"ajv",
"ansi-regex",
"ansi-styles",
"aproba",
"are-we-there-yet",
"asap",
"asn1",
"assert-plus",
"asynckit",
"aws-sign2",
"aws4",
"balanced-match",
"bcrypt-pbkdf",
"bin-links",
"binary-extensions",
"brace-expansion",
"builtins",
"caseless",
"cidr-regex",
"clean-stack",
"clone",
"cmd-shim",
"code-point-at",
"color-convert",
"color-name",
"colors",
"combined-stream",
"common-ancestor-path",
"concat-map",
"console-control-strings",
"core-util-is",
"dashdash",
"debug",
"debuglog",
"defaults",
"delayed-stream",
"delegates",
"depd",
"dezalgo",
"diff",
"ecc-jsbn",
"emoji-regex",
"encoding",
"env-paths",
"err-code",
"extend",
"extsprintf",
"fast-deep-equal",
"fast-json-stable-stringify",
"forever-agent",
"fs-minipass",
"fs.realpath",
"function-bind",
"gauge",
"getpass",
"har-schema",
"har-validator",
"has",
"has-flag",
"has-unicode",
"http-cache-semantics",
"http-proxy-agent",
"http-signature",
"https-proxy-agent",
"humanize-ms",
"iconv-lite",
"ignore-walk",
"imurmurhash",
"indent-string",
"infer-owner",
"inflight",
"inherits",
"ip",
"ip-regex",
"is-core-module",
"is-fullwidth-code-point",
"is-lambda",
"is-typedarray",
"isarray",
"isexe",
"isstream",
"jsbn",
"json-schema",
"json-schema-traverse",
"json-stringify-nice",
"json-stringify-safe",
"jsonparse",
"jsprim",
"just-diff",
"just-diff-apply",
"lru-cache",
"mime-db",
"mime-types",
"minimatch",
"minipass-collect",
"minipass-fetch",
"minipass-flush",
"minipass-json-stream",
"minipass-sized",
"minizlib",
"mute-stream",
"negotiator",
"normalize-package-data",
"npm-bundled",
"npm-install-checks",
"npm-normalize-package-bin",
"npm-packlist",
"number-is-nan",
"oauth-sign",
"object-assign",
"once",
"p-map",
"path-is-absolute",
"path-parse",
"performance-now",
"proc-log",
"process-nextick-args",
"promise-all-reject-late",
"promise-call-limit",
"promise-inflight",
"promise-retry",
"promzard",
"psl",
"punycode",
"qs",
"read-cmd-shim",
"readable-stream",
"request",
"resolve",
"retry",
"safe-buffer",
"safer-buffer",
"set-blocking",
"signal-exit",
"smart-buffer",
"socks",
"socks-proxy-agent",
"spdx-correct",
"spdx-exceptions",
"spdx-expression-parse",
"spdx-license-ids",
"sshpk",
"string_decoder",
"string-width",
"stringify-package",
"strip-ansi",
"supports-color",
"tunnel-agent",
"tweetnacl",
"typedarray-to-buffer",
"unique-filename",
"unique-slug",
"uri-js",
"util-deprecate",
"uuid",
"validate-npm-package-license",
"verror",
"walk-up-path",
"wcwidth",
"wide-align",
"wrappy",
"yallist"
], ],
"dev": true, "dev": true,
"license": "Artistic-2.0", "license": "Artistic-2.0",
@@ -14604,7 +14780,9 @@
} }
}, },
"@sasjs/utils": { "@sasjs/utils": {
"version": "2.24.0", "version": "2.25.1",
"resolved": "https://registry.npmjs.org/@sasjs/utils/-/utils-2.25.1.tgz",
"integrity": "sha512-lwkPE+QsB81b8/1M8X2zLdhpuiA8pIjgOwJH57zhcsliuDnNs4uijSYu40aYSc8tH98jtSuqWMjfGq8CT9o1Dw==",
"requires": { "requires": {
"@types/prompts": "^2.0.13", "@types/prompts": "^2.0.13",
"chalk": "^4.1.1", "chalk": "^4.1.1",

View File

@@ -67,7 +67,7 @@
}, },
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@sasjs/utils": "^2.24.0", "@sasjs/utils": "^2.25.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"axios-cookiejar-support": "^1.0.1", "axios-cookiejar-support": "^1.0.1",
"form-data": "^4.0.0", "form-data": "^4.0.0",

View File

@@ -754,18 +754,16 @@ export class SASViyaApiClient {
jobDefinition, jobDefinition,
arguments: jobArguments arguments: jobArguments
} }
const { result: postedJob, etag } = await this.requestClient.post<Job>( const { result: postedJob } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`, `${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequestBody, postJobRequestBody,
access_token access_token
) )
const jobStatus = await this.pollJobState( const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
postedJob, (err) => {
etag, throw prefixMessage(err, 'Error while polling job status. ')
authConfig }
).catch((err) => { )
throw prefixMessage(err, 'Error while polling job status. ')
})
const { result: currentJob } = await this.requestClient.get<Job>( const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`, `${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
access_token access_token
@@ -833,7 +831,6 @@ export class SASViyaApiClient {
private async pollJobState( private async pollJobState(
postedJob: Job, postedJob: Job,
etag: string | null,
authConfig?: AuthConfig, authConfig?: AuthConfig,
pollOptions?: PollOptions pollOptions?: PollOptions
) { ) {
@@ -841,7 +838,6 @@ export class SASViyaApiClient {
this.requestClient, this.requestClient,
postedJob, postedJob,
this.debug, this.debug,
etag,
authConfig, authConfig,
pollOptions pollOptions
) )

View File

@@ -178,7 +178,6 @@ export async function executeScript(
requestClient, requestClient,
postedJob, postedJob,
debug, debug,
etag,
authConfig, authConfig,
pollOptions pollOptions
).catch(async (err) => { ).catch(async (err) => {

View File

@@ -1,16 +1,19 @@
import { AuthConfig } from '@sasjs/utils/types' import { AuthConfig } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import { generateTimestamp } from '@sasjs/utils/time'
import { Job, PollOptions } from '../..' import { Job, PollOptions } from '../..'
import { getTokens } from '../../auth/getTokens' import { getTokens } from '../../auth/getTokens'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { JobStatePollError } from '../../types/errors'
import { generateTimestamp } from '@sasjs/utils/time'
import { saveLog } from './saveLog' import { saveLog } from './saveLog'
import { createWriteStream } from '@sasjs/utils/file'
import { WriteStream } from 'fs'
import { Link } from '../../types'
import { prefixMessage } from '@sasjs/utils/error'
export async function pollJobState( export async function pollJobState(
requestClient: RequestClient, requestClient: RequestClient,
postedJob: Job, postedJob: Job,
debug: boolean, debug: boolean,
etag: string | null,
authConfig?: AuthConfig, authConfig?: AuthConfig,
pollOptions?: PollOptions pollOptions?: PollOptions
) { ) {
@@ -18,130 +21,226 @@ export async function pollJobState(
let pollInterval = 300 let pollInterval = 300
let maxPollCount = 1000 let maxPollCount = 1000
let maxErrorCount = 5
let access_token = (authConfig || {}).access_token
const logFileName = `${postedJob.name || 'job'}-${generateTimestamp()}.log`
const logFilePath = `${
pollOptions?.logFolderPath || process.cwd()
}/${logFileName}`
if (authConfig) {
;({ access_token } = await getTokens(requestClient, authConfig))
}
if (pollOptions) { if (pollOptions) {
pollInterval = pollOptions.pollInterval || pollInterval pollInterval = pollOptions.pollInterval || pollInterval
maxPollCount = pollOptions.maxPollCount || maxPollCount maxPollCount = pollOptions.maxPollCount || maxPollCount
} }
let postedJobState = ''
let pollCount = 0
let errorCount = 0
const headers: any = {
'Content-Type': 'application/json',
'If-None-Match': etag
}
if (access_token) {
headers.Authorization = `Bearer ${access_token}`
}
const stateLink = postedJob.links.find((l: any) => l.rel === 'state') const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
if (!stateLink) { if (!stateLink) {
throw new Error(`Job state link was not found.`) throw new Error(`Job state link was not found.`)
} }
const { result: state } = await requestClient let currentState = await getJobState(
.get<string>( requestClient,
`${stateLink.href}?_action=wait&wait=300`, postedJob,
access_token, '',
'text/plain', debug,
{}, authConfig
debug ).catch((err) => {
logger.error(
`Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`,
err
) )
.catch((err) => { return 'unavailable'
logger.error( })
`Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`,
err let pollCount = 0
)
return { result: 'unavailable' }
})
const currentState = state.trim()
if (currentState === 'completed') { if (currentState === 'completed') {
return Promise.resolve(currentState) return Promise.resolve(currentState)
} }
return new Promise(async (resolve, reject) => { let logFileStream
let printedState = '' if (pollOptions?.streamLog) {
const logFileName = `${postedJob.name || 'job'}-${generateTimestamp()}.log`
const logFilePath = `${
pollOptions?.logFolderPath || process.cwd()
}/${logFileName}`
const interval = setInterval(async () => { logFileStream = await createWriteStream(logFilePath)
if ( }
postedJobState === 'running' ||
postedJobState === '' ||
postedJobState === 'pending' ||
postedJobState === 'unavailable'
) {
if (authConfig) {
;({ access_token } = await getTokens(requestClient, authConfig))
}
if (stateLink) { let result = await doPoll(
const { result: jobState } = await requestClient requestClient,
.get<string>( postedJob,
`${stateLink.href}?_action=wait&wait=300`, currentState,
access_token, debug,
'text/plain', pollCount,
{}, authConfig,
debug pollOptions,
) logFileStream
.catch((err) => { )
errorCount++
if (pollCount >= maxPollCount || errorCount >= maxErrorCount) {
clearInterval(interval)
reject(
prefixMessage(
err,
'Error while getting job state after interval. '
)
)
}
logger.error(
`Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`,
err
)
return { result: 'unavailable' }
})
postedJobState = jobState.trim() currentState = result.state
if (postedJobState != 'unavailable' && errorCount > 0) { pollCount = result.pollCount
errorCount = 0
}
if (debug && printedState !== postedJobState) { if (!needsRetry(currentState) || pollCount >= maxPollCount) {
logger.info('Polling job status...') return currentState
logger.info(`Current job state: ${postedJobState}`) }
printedState = postedJobState // If we get to this point, this is a long-running job that needs longer polling.
} // We will resume polling with a bigger interval of 1 minute
let longJobPollOptions: PollOptions = {
maxPollCount: 24 * 60,
pollInterval: 60000,
streamLog: false
}
if (pollOptions) {
longJobPollOptions.streamLog = pollOptions.streamLog
longJobPollOptions.logFolderPath = pollOptions.logFolderPath
}
pollCount++ result = await doPoll(
requestClient,
postedJob,
currentState,
debug,
pollCount,
authConfig,
longJobPollOptions,
logFileStream
)
await saveLog( currentState = result.state
postedJob, pollCount = result.pollCount
requestClient,
pollOptions?.streamLog || false,
logFilePath,
access_token
)
if (pollCount >= maxPollCount) { if (logFileStream) {
resolve(postedJobState) logFileStream.end()
} }
}
} else { return currentState
clearInterval(interval)
resolve(postedJobState)
}
}, pollInterval)
})
} }
const getJobState = async (
requestClient: RequestClient,
job: Job,
currentState: string,
debug: boolean,
authConfig?: AuthConfig
) => {
const stateLink = job.links.find((l: any) => l.rel === 'state')
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
if (needsRetry(currentState)) {
let tokens
if (authConfig) {
tokens = await getTokens(requestClient, authConfig)
}
const { result: jobState } = await requestClient
.get<string>(
`${stateLink.href}?_action=wait&wait=300`,
tokens?.access_token,
'text/plain',
{},
debug
)
.catch((err) => {
throw new JobStatePollError(job.id, err)
})
return jobState.trim()
} else {
return currentState
}
}
const needsRetry = (state: string) =>
state === 'running' ||
state === '' ||
state === 'pending' ||
state === 'unavailable'
const doPoll = async (
requestClient: RequestClient,
postedJob: Job,
currentState: string,
debug: boolean,
pollCount: number,
authConfig?: AuthConfig,
pollOptions?: PollOptions,
logStream?: WriteStream
): Promise<{ state: string; pollCount: number }> => {
let pollInterval = 300
let maxPollCount = 1000
let maxErrorCount = 5
let errorCount = 0
let state = currentState
let printedState = ''
let startLogLine = 0
const logger = process.logger || console
if (pollOptions) {
pollInterval = pollOptions.pollInterval || pollInterval
maxPollCount = pollOptions.maxPollCount || maxPollCount
}
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
while (needsRetry(state) && pollCount <= 100 && pollCount <= maxPollCount) {
state = await getJobState(
requestClient,
postedJob,
state,
debug,
authConfig
).catch((err) => {
errorCount++
if (pollCount >= maxPollCount || errorCount >= maxErrorCount) {
throw err
}
logger.error(
`Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`,
err
)
return 'unavailable'
})
pollCount++
const jobUrl = postedJob.links.find((l: Link) => l.rel === 'self')
const { result: job } = await requestClient.get<Job>(
jobUrl!.href,
authConfig?.access_token
)
const endLogLine = job.logStatistics?.lineCount ?? 1000000
await saveLog(
postedJob,
requestClient,
pollOptions?.streamLog || false,
startLogLine,
endLogLine,
logStream,
authConfig?.access_token
)
startLogLine += job.logStatistics.lineCount
if (debug && printedState !== state) {
logger.info('Polling job status...')
logger.info(`Current job state: ${state}`)
printedState = state
}
if (state != 'unavailable' && errorCount > 0) {
errorCount = 0
}
await delay(pollInterval)
}
return { state, pollCount }
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

View File

@@ -1,15 +1,19 @@
import { createFile } from '@sasjs/utils/file'
import { Job } from '../..' import { Job } from '../..'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { fetchLogByChunks } from '../../utils' import { fetchLog } from '../../utils'
import { WriteStream } from 'fs'
import { writeStream } from './writeStream'
export async function saveLog( export async function saveLog(
job: Job, job: Job,
requestClient: RequestClient, requestClient: RequestClient,
shouldSaveLog: boolean, shouldSaveLog: boolean,
logFilePath: string, startLine: number,
endLine: number,
logFileStream?: WriteStream,
accessToken?: string accessToken?: string
) { ) {
console.log('startLine: ', startLine, ' endLine: ', endLine)
if (!shouldSaveLog) { if (!shouldSaveLog) {
return return
} }
@@ -20,6 +24,12 @@ export async function saveLog(
) )
} }
if (!logFileStream) {
throw new Error(
`Logs for job ${job.id} cannot be written without a valid write stream.`
)
}
const logger = process.logger || console const logger = process.logger || console
const jobLogUrl = job.links.find((l) => l.rel === 'log') const jobLogUrl = job.links.find((l) => l.rel === 'log')
@@ -27,14 +37,14 @@ export async function saveLog(
throw new Error(`Log URL for job ${job.id} was not found.`) throw new Error(`Log URL for job ${job.id} was not found.`)
} }
const logCount = job.logStatistics?.lineCount ?? 1000000 const log = await fetchLog(
const log = await fetchLogByChunks(
requestClient, requestClient,
accessToken, accessToken,
`${jobLogUrl.href}/content`, `${jobLogUrl.href}/content`,
logCount startLine,
) endLine
).catch((e) => console.log(e))
logger.info(`Writing logs to ${logFilePath}`) logger.info(`Writing logs to ${logFileStream.path}`)
await createFile(logFilePath, log) await writeStream(logFileStream, log || '').catch((e) => console.log(e))
} }

View File

@@ -344,7 +344,6 @@ describe('executeScript', () => {
requestClient, requestClient,
mockJob, mockJob,
false, false,
'',
mockAuthConfig, mockAuthConfig,
defaultPollOptions defaultPollOptions
) )
@@ -546,7 +545,7 @@ describe('executeScript', () => {
if (url.includes('_webout')) { if (url.includes('_webout')) {
return Promise.reject(new NotFoundError(url)) return Promise.reject(new NotFoundError(url))
} }
return Promise.resolve({ result: mockJob, etag: '' }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}) })
const error = await executeScript( const error = await executeScript(
@@ -645,7 +644,9 @@ const setupMocks = () => {
.mockImplementation(() => Promise.resolve({ result: mockJob, etag: '' })) .mockImplementation(() => Promise.resolve({ result: mockJob, etag: '' }))
jest jest
.spyOn(requestClient, 'get') .spyOn(requestClient, 'get')
.mockImplementation(() => Promise.resolve({ result: mockJob, etag: '' })) .mockImplementation(() =>
Promise.resolve({ result: mockJob, etag: '', status: 200 })
)
jest jest
.spyOn(requestClient, 'delete') .spyOn(requestClient, 'delete')
.mockImplementation(() => Promise.resolve({ result: {}, etag: '' })) .mockImplementation(() => Promise.resolve({ result: {}, etag: '' }))
@@ -658,7 +659,7 @@ const setupMocks = () => {
jest jest
.spyOn(sessionManager, 'getVariable') .spyOn(sessionManager, 'getVariable')
.mockImplementation(() => .mockImplementation(() =>
Promise.resolve({ result: { value: 'test' }, etag: 'test' }) Promise.resolve({ result: { value: 'test' }, etag: 'test', status: 200 })
) )
jest jest
.spyOn(sessionManager, 'getSession') .spyOn(sessionManager, 'getSession')

View File

@@ -31,6 +31,13 @@ export const mockJob: Job = {
type: 'log', type: 'log',
uri: 'log' uri: 'log'
}, },
{
rel: 'self',
href: '/job',
method: 'GET',
type: 'job',
uri: 'job'
},
{ {
rel: 'state', rel: 'state',
href: '/state', href: '/state',
@@ -54,3 +61,13 @@ export const mockAuthConfig: AuthConfig = {
access_token: 'acc355', access_token: 'acc355',
refresh_token: 'r3fr35h' refresh_token: 'r3fr35h'
} }
export class MockStream {
_write(chunk: string, _: any, next: Function) {
next()
}
reset() {}
destroy() {}
}

View File

@@ -1,10 +1,11 @@
import * as fs from 'fs'
import { Logger, LogLevel } from '@sasjs/utils'
import { RequestClient } from '../../../request/RequestClient' import { RequestClient } from '../../../request/RequestClient'
import { mockAuthConfig, mockJob } from './mockResponses' import { mockAuthConfig, mockJob } from './mockResponses'
import { pollJobState } from '../pollJobState' import { pollJobState } from '../pollJobState'
import * as getTokensModule from '../../../auth/getTokens' import * as getTokensModule from '../../../auth/getTokens'
import * as saveLogModule from '../saveLog' import * as saveLogModule from '../saveLog'
import { PollOptions } from '../../../types' import { PollOptions } from '../../../types'
import { Logger, LogLevel } from '@sasjs/utils'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)() const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const defaultPollOptions: PollOptions = { const defaultPollOptions: PollOptions = {
@@ -24,7 +25,6 @@ describe('pollJobState', () => {
requestClient, requestClient,
mockJob, mockJob,
false, false,
'test',
mockAuthConfig, mockAuthConfig,
defaultPollOptions defaultPollOptions
) )
@@ -40,7 +40,6 @@ describe('pollJobState', () => {
requestClient, requestClient,
mockJob, mockJob,
false, false,
'test',
undefined, undefined,
defaultPollOptions defaultPollOptions
) )
@@ -53,7 +52,6 @@ describe('pollJobState', () => {
requestClient, requestClient,
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') }, { ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') },
false, false,
'test',
undefined, undefined,
defaultPollOptions defaultPollOptions
).catch((e) => e) ).catch((e) => e)
@@ -62,23 +60,12 @@ describe('pollJobState', () => {
}) })
it('should attempt to refresh tokens before each poll', async () => { it('should attempt to refresh tokens before each poll', async () => {
jest mockSimplePoll()
.spyOn(requestClient, 'get')
.mockImplementationOnce(() =>
Promise.resolve({ result: 'pending', etag: '' })
)
.mockImplementationOnce(() =>
Promise.resolve({ result: 'running', etag: '' })
)
.mockImplementation(() =>
Promise.resolve({ result: 'completed', etag: '' })
)
await pollJobState( await pollJobState(
requestClient, requestClient,
mockJob, mockJob,
false, false,
'test',
mockAuthConfig, mockAuthConfig,
defaultPollOptions defaultPollOptions
) )
@@ -87,23 +74,12 @@ describe('pollJobState', () => {
}) })
it('should attempt to fetch and save the log after each poll', async () => { it('should attempt to fetch and save the log after each poll', async () => {
jest mockSimplePoll()
.spyOn(requestClient, 'get')
.mockImplementationOnce(() =>
Promise.resolve({ result: 'pending', etag: '' })
)
.mockImplementationOnce(() =>
Promise.resolve({ result: 'running', etag: '' })
)
.mockImplementation(() =>
Promise.resolve({ result: 'completed', etag: '' })
)
await pollJobState( await pollJobState(
requestClient, requestClient,
mockJob, mockJob,
false, false,
'test',
mockAuthConfig, mockAuthConfig,
defaultPollOptions defaultPollOptions
) )
@@ -112,20 +88,12 @@ describe('pollJobState', () => {
}) })
it('should return the current status when the max poll count is reached', async () => { it('should return the current status when the max poll count is reached', async () => {
jest mockRunningPoll()
.spyOn(requestClient, 'get')
.mockImplementationOnce(() =>
Promise.resolve({ result: 'pending', etag: '' })
)
.mockImplementationOnce(() =>
Promise.resolve({ result: 'running', etag: '' })
)
const state = await pollJobState( const state = await pollJobState(
requestClient, requestClient,
mockJob, mockJob,
false, false,
'test',
mockAuthConfig, mockAuthConfig,
{ {
...defaultPollOptions, ...defaultPollOptions,
@@ -136,51 +104,47 @@ describe('pollJobState', () => {
expect(state).toEqual('running') expect(state).toEqual('running')
}) })
it('should continue polling until the job completes or errors', async () => { it('should poll with a larger interval for longer running jobs', async () => {
jest mockLongPoll()
.spyOn(requestClient, 'get')
.mockImplementationOnce(() => const state = await pollJobState(
Promise.resolve({ result: 'pending', etag: '' }) requestClient,
) mockJob,
.mockImplementationOnce(() => false,
Promise.resolve({ result: 'running', etag: '' }) mockAuthConfig,
) {
.mockImplementation(() => ...defaultPollOptions,
Promise.resolve({ result: 'completed', etag: '' }) maxPollCount: 200,
) pollInterval: 10
}
)
expect(state).toEqual('completed')
}, 200000)
it('should continue polling until the job completes or errors', async () => {
mockSimplePoll(1)
const state = await pollJobState( const state = await pollJobState(
requestClient, requestClient,
mockJob, mockJob,
false, false,
'test',
undefined, undefined,
defaultPollOptions defaultPollOptions
) )
expect(requestClient.get).toHaveBeenCalledTimes(4) expect(requestClient.get).toHaveBeenCalledTimes(3)
expect(state).toEqual('completed') expect(state).toEqual('completed')
}) })
it('should print the state to the console when debug is on', async () => { it('should print the state to the console when debug is on', async () => {
jest.spyOn((process as any).logger, 'info') jest.spyOn((process as any).logger, 'info')
jest mockSimplePoll()
.spyOn(requestClient, 'get')
.mockImplementationOnce(() =>
Promise.resolve({ result: 'pending', etag: '' })
)
.mockImplementationOnce(() =>
Promise.resolve({ result: 'running', etag: '' })
)
.mockImplementation(() =>
Promise.resolve({ result: 'completed', etag: '' })
)
await pollJobState( await pollJobState(
requestClient, requestClient,
mockJob, mockJob,
true, true,
'test',
undefined, undefined,
defaultPollOptions defaultPollOptions
) )
@@ -205,21 +169,12 @@ describe('pollJobState', () => {
}) })
it('should continue polling when there is a single error in between', async () => { it('should continue polling when there is a single error in between', async () => {
jest mockPollWithSingleError()
.spyOn(requestClient, 'get')
.mockImplementationOnce(() =>
Promise.resolve({ result: 'pending', etag: '' })
)
.mockImplementationOnce(() => Promise.reject('Status Error'))
.mockImplementationOnce(() =>
Promise.resolve({ result: 'completed', etag: '' })
)
const state = await pollJobState( const state = await pollJobState(
requestClient, requestClient,
mockJob, mockJob,
false, false,
'test',
undefined, undefined,
defaultPollOptions defaultPollOptions
) )
@@ -229,20 +184,19 @@ describe('pollJobState', () => {
}) })
it('should throw an error when the error count exceeds the set value of 5', async () => { it('should throw an error when the error count exceeds the set value of 5', async () => {
jest mockErroredPoll()
.spyOn(requestClient, 'get')
.mockImplementation(() => Promise.reject('Status Error'))
const error = await pollJobState( const error = await pollJobState(
requestClient, requestClient,
mockJob, mockJob,
false, false,
'test',
undefined, undefined,
defaultPollOptions defaultPollOptions
).catch((e) => e) ).catch((e) => e)
expect(error).toContain('Error while getting job state after interval.') expect(error.message).toEqual(
'Error while polling job state for job j0b: Status Error'
)
}) })
}) })
@@ -251,11 +205,12 @@ const setupMocks = () => {
jest.mock('../../../request/RequestClient') jest.mock('../../../request/RequestClient')
jest.mock('../../../auth/getTokens') jest.mock('../../../auth/getTokens')
jest.mock('../saveLog') jest.mock('../saveLog')
jest.mock('fs')
jest jest
.spyOn(requestClient, 'get') .spyOn(requestClient, 'get')
.mockImplementation(() => .mockImplementation(() =>
Promise.resolve({ result: 'completed', etag: '' }) Promise.resolve({ result: 'completed', etag: '', status: 200 })
) )
jest jest
.spyOn(getTokensModule, 'getTokens') .spyOn(getTokensModule, 'getTokens')
@@ -263,4 +218,84 @@ const setupMocks = () => {
jest jest
.spyOn(saveLogModule, 'saveLog') .spyOn(saveLogModule, 'saveLog')
.mockImplementation(() => Promise.resolve()) .mockImplementation(() => Promise.resolve())
jest
.spyOn(fs, 'createWriteStream')
.mockImplementation(() => ({} as unknown as fs.WriteStream))
}
const mockSimplePoll = (runningCount = 2) => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.resolve({
result:
count === 0
? 'pending'
: count <= runningCount
? 'running'
: 'completed',
etag: '',
status: 200
})
})
}
const mockRunningPoll = () => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.resolve({
result: count === 0 ? 'pending' : 'running',
etag: '',
status: 200
})
})
}
const mockLongPoll = () => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.resolve({
result: count <= 101 ? 'running' : 'completed',
etag: '',
status: 200
})
})
}
const mockPollWithSingleError = () => {
let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
if (count === 1) {
return Promise.reject('Status Error')
}
return Promise.resolve({
result: count === 0 ? 'pending' : 'completed',
etag: '',
status: 200
})
})
}
const mockErroredPoll = () => {
jest.spyOn(requestClient, 'get').mockImplementation((url) => {
if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}
return Promise.reject('Status Error')
})
} }

View File

@@ -1,11 +1,13 @@
import { Logger, LogLevel } from '@sasjs/utils' import { Logger, LogLevel } from '@sasjs/utils'
import * as fileModule from '@sasjs/utils/file'
import { RequestClient } from '../../../request/RequestClient' import { RequestClient } from '../../../request/RequestClient'
import * as fetchLogsModule from '../../../utils/fetchLogByChunks' import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
import * as writeStreamModule from '../writeStream'
import { saveLog } from '../saveLog' import { saveLog } from '../saveLog'
import { mockJob } from './mockResponses' import { mockJob } from './mockResponses'
import { WriteStream } from 'fs'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)() const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const stream = {} as unknown as WriteStream
describe('saveLog', () => { describe('saveLog', () => {
beforeEach(() => { beforeEach(() => {
@@ -14,16 +16,21 @@ describe('saveLog', () => {
}) })
it('should return immediately if shouldSaveLog is false', async () => { it('should return immediately if shouldSaveLog is false', async () => {
await saveLog(mockJob, requestClient, false, '/test', 't0k3n') await saveLog(mockJob, requestClient, false, 0, 100, stream, 't0k3n')
expect(fetchLogsModule.fetchLogByChunks).not.toHaveBeenCalled() expect(fetchLogsModule.fetchLog).not.toHaveBeenCalled()
expect(fileModule.createFile).not.toHaveBeenCalled() expect(writeStreamModule.writeStream).not.toHaveBeenCalled()
}) })
it('should throw an error when a valid access token is not provided', async () => { it('should throw an error when a valid access token is not provided', async () => {
const error = await saveLog(mockJob, requestClient, true, '/test').catch( const error = await saveLog(
(e) => e mockJob,
) requestClient,
true,
0,
100,
stream
).catch((e) => e)
expect(error.message).toContain( expect(error.message).toContain(
`Logs for job ${mockJob.id} cannot be fetched without a valid access token.` `Logs for job ${mockJob.id} cannot be fetched without a valid access token.`
@@ -35,7 +42,9 @@ describe('saveLog', () => {
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'log') }, { ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'log') },
requestClient, requestClient,
true, true,
'/test', 0,
100,
stream,
't0k3n' 't0k3n'
).catch((e) => e) ).catch((e) => e)
@@ -45,15 +54,19 @@ describe('saveLog', () => {
}) })
it('should fetch and save logs to the given path', async () => { it('should fetch and save logs to the given path', async () => {
await saveLog(mockJob, requestClient, true, '/test', 't0k3n') await saveLog(mockJob, requestClient, true, 0, 100, stream, 't0k3n')
expect(fetchLogsModule.fetchLogByChunks).toHaveBeenCalledWith( expect(fetchLogsModule.fetchLog).toHaveBeenCalledWith(
requestClient, requestClient,
't0k3n', 't0k3n',
'/log/content', '/log/content',
0,
100 100
) )
expect(fileModule.createFile).toHaveBeenCalledWith('/test', 'Test Log') expect(writeStreamModule.writeStream).toHaveBeenCalledWith(
stream,
'Test Log'
)
}) })
}) })
@@ -62,11 +75,12 @@ const setupMocks = () => {
jest.mock('../../../request/RequestClient') jest.mock('../../../request/RequestClient')
jest.mock('../../../utils/fetchLogByChunks') jest.mock('../../../utils/fetchLogByChunks')
jest.mock('@sasjs/utils') jest.mock('@sasjs/utils')
jest.mock('../writeStream')
jest jest
.spyOn(fetchLogsModule, 'fetchLogByChunks') .spyOn(fetchLogsModule, 'fetchLog')
.mockImplementation(() => Promise.resolve('Test Log')) .mockImplementation(() => Promise.resolve('Test Log'))
jest jest
.spyOn(fileModule, 'createFile') .spyOn(writeStreamModule, 'writeStream')
.mockImplementation(() => Promise.resolve()) .mockImplementation(() => Promise.resolve())
} }

View File

@@ -0,0 +1,15 @@
import { WriteStream } from 'fs'
export const writeStream = async (
stream: WriteStream,
content: string
): Promise<void> => {
return new Promise((resolve, reject) => {
stream.write(content + '\n\nnext chunk\n\n', (e) => {
if (e) {
return reject(e)
}
return resolve()
})
})
}

View File

@@ -0,0 +1,11 @@
export class JobStatePollError extends Error {
constructor(id: string, public originalError: Error) {
super(
`Error while polling job state for job ${id}: ${
originalError.message || originalError
}`
)
this.name = 'JobStatePollError'
Object.setPrototypeOf(this, JobStatePollError.prototype)
}
}

View File

@@ -2,6 +2,7 @@ export * from './AuthorizeError'
export * from './ComputeJobExecutionError' export * from './ComputeJobExecutionError'
export * from './InternalServerError' export * from './InternalServerError'
export * from './JobExecutionError' export * from './JobExecutionError'
export * from './JobStatePollError'
export * from './LoginRequiredError' export * from './LoginRequiredError'
export * from './NotFoundError' export * from './NotFoundError'
export * from './ErrorResponse' export * from './ErrorResponse'

View File

@@ -14,18 +14,36 @@ export const fetchLogByChunks = async (
accessToken: string, accessToken: string,
logUrl: string, logUrl: string,
logCount: number logCount: number
): Promise<string> => {
return await fetchLog(requestClient, accessToken, logUrl, 0, logCount)
}
/**
* Fetches a section of the log file delineated by start and end lines
* @param {object} requestClient - client object of Request Client.
* @param {string} accessToken - an access token for an authorized user.
* @param {string} logUrl - url of the log file.
* @param {number} start - the line at which to start fetching the log.
* @param {number} end - the line at which to stop fetching the log.
* @returns an string containing log lines.
*/
export const fetchLog = async (
requestClient: RequestClient,
accessToken: string,
logUrl: string,
start: number,
end: number
): Promise<string> => { ): Promise<string> => {
const logger = process.logger || console const logger = process.logger || console
let log: string = '' let log: string = ''
const loglimit = logCount < 10000 ? logCount : 10000 const loglimit = end < 10000 ? end : 10000
let start = 0
do { do {
logger.info( logger.info(
`Fetching logs from line no: ${start + 1} to ${ `Fetching logs from line no: ${start + 1} to ${
start + loglimit start + loglimit
} of ${logCount}.` } of ${end}.`
) )
const logChunkJson = await requestClient! const logChunkJson = await requestClient!
.get<any>(`${logUrl}?start=${start}&limit=${loglimit}`, accessToken) .get<any>(`${logUrl}?start=${start}&limit=${loglimit}`, accessToken)
@@ -40,6 +58,6 @@ export const fetchLogByChunks = async (
log += logChunk log += logChunk
start += loglimit start += loglimit
} while (start < logCount) } while (start < end)
return log return log
} }