diff --git a/.vscode/settings.json b/.vscode/settings.json index 9a770f0..c15453a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,3 @@ { - "cSpell.words": [ - "autoexec" - ] + "cSpell.words": ["autoexec", "initialising"] } diff --git a/api/public/swagger.yaml b/api/public/swagger.yaml index c9b00c6..f7929f0 100644 --- a/api/public/swagger.yaml +++ b/api/public/swagger.yaml @@ -113,8 +113,8 @@ components: properties: sessionId: type: string - description: "The SessionId is the name of the temporary folder used to store the outputs.\nFor SAS, this would be the SASWORK folder. Can be used to poll job status.\nThis session ID should be used to poll job status." - example: '{ sessionId: ''20241028074744-54132-1730101664824'' }' + description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store code outputs.

\nFor SAS, this would be the location of the SASWORK folder.

\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint." + example: 20241028074744-54132-1730101664824 required: - sessionId type: object @@ -585,6 +585,14 @@ components: - needsToUpdatePassword type: object additionalProperties: false + SessionState: + enum: + - initialising + - pending + - running + - completed + - failed + type: string ExecutePostRequestPayload: properties: _program: @@ -597,8 +605,8 @@ components: properties: sessionId: type: string - description: "The SessionId is the name of the temporary folder used to store the outputs.\nFor SAS, this would be the SASWORK folder. Can be used to poll program status.\nThis session ID should be used to poll program status." - example: '{ sessionId: ''20241028074744-54132-1730101664824'' }' + description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store program outputs.

\nFor SAS, this would be the location of the SASWORK folder.

\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint." + example: 20241028074744-54132-1730101664824 required: - sessionId type: object @@ -1841,6 +1849,30 @@ paths: - bearerAuth: [] parameters: [] + '/SASjsApi/session/{sessionId}/state': + get: + operationId: SessionState + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/SessionState' + description: "The polling endpoint is currently implemented for single-server deployments only.
\nLoad balanced / grid topologies will be supported in a future release.
\nIf your site requires this, please reach out to SASjs Support." + summary: 'Get session state (initialising, pending, running, completed, failed).' + tags: + - Session + security: + - + bearerAuth: [] + parameters: + - + in: path + name: sessionId + required: true + schema: + type: string /SASjsApi/stp/execute: get: operationId: ExecuteGetRequest diff --git a/api/src/controllers/code.ts b/api/src/controllers/code.ts index 039ce3c..f6aaa30 100644 --- a/api/src/controllers/code.ts +++ b/api/src/controllers/code.ts @@ -42,10 +42,12 @@ interface TriggerCodePayload { interface TriggerCodeResponse { /** - * The SessionId is the name of the temporary folder used to store the outputs. - * For SAS, this would be the SASWORK folder. Can be used to poll job status. - * This session ID should be used to poll job status. - * @example "{ sessionId: '20241028074744-54132-1730101664824' }" + * `sessionId` is the ID of the session and the name of the temporary folder + * used to store code outputs.

+ * For SAS, this would be the location of the SASWORK folder.

+ * `sessionId` can be used to poll session state using the + * GET /SASjsApi/session/{sessionId}/state endpoint. + * @example "20241028074744-54132-1730101664824" */ sessionId: string } diff --git a/api/src/controllers/internal/Execution.ts b/api/src/controllers/internal/Execution.ts index 3004f56..c1ff197 100644 --- a/api/src/controllers/internal/Execution.ts +++ b/api/src/controllers/internal/Execution.ts @@ -2,7 +2,7 @@ import path from 'path' import fs from 'fs' import { getSessionController, processProgram } from './' import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils' -import { PreProgramVars, Session, TreeNode } from '../../types' +import { PreProgramVars, Session, TreeNode, SessionState } from '../../types' import { extractHeaders, getFilesFolder, @@ -75,8 +75,7 @@ export class ExecutionController { const session = sessionByFileUpload ?? (await sessionController.getSession()) - session.inUse = true - session.consumed = true + session.state = SessionState.running const logPath = path.join(session.path, 'log.log') const headersPath = path.join(session.path, 'stpsrv_header.txt') @@ -121,7 +120,7 @@ export class ExecutionController { : '' // it should be deleted by scheduleSessionDestroy - session.inUse = false + session.state = SessionState.completed const resultParts = [] @@ -145,7 +144,9 @@ export class ExecutionController { return { httpHeaders, result: - isDebugOn(vars) || session.crashed ? resultParts.join(`\n`) : webout + isDebugOn(vars) || session.failureReason + ? resultParts.join(`\n`) + : webout } } diff --git a/api/src/controllers/internal/FileUploadController.ts b/api/src/controllers/internal/FileUploadController.ts index a06f512..f234772 100644 --- a/api/src/controllers/internal/FileUploadController.ts +++ b/api/src/controllers/internal/FileUploadController.ts @@ -2,11 +2,8 @@ import { Request, RequestHandler } from 'express' import multer from 'multer' import { uuidv4 } from '@sasjs/utils' import { getSessionController } from '.' -import { - executeProgramRawValidation, - getRunTimeAndFilePath, - RunTimeType -} from '../../utils' +import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils' +import { SessionState } from '../../types' export class FileUploadController { private storage = multer.diskStorage({ @@ -56,9 +53,8 @@ export class FileUploadController { } const session = await sessionController.getSession() - // marking consumed true, so that it's not available - // as readySession for any other request - session.consumed = true + // change session state to 'running', so that it's not available for any other request + session.state = SessionState.running req.sasjsSession = session diff --git a/api/src/controllers/internal/Session.ts b/api/src/controllers/internal/Session.ts index 3000c07..751d306 100644 --- a/api/src/controllers/internal/Session.ts +++ b/api/src/controllers/internal/Session.ts @@ -1,5 +1,5 @@ import path from 'path' -import { Session } from '../../types' +import { Session, SessionState } from '../../types' import { promisify } from 'util' import { execFile } from 'child_process' import { @@ -23,7 +23,9 @@ export class SessionController { protected sessions: Session[] = [] protected getReadySessions = (): Session[] => - this.sessions.filter((sess: Session) => sess.ready && !sess.consumed) + this.sessions.filter( + (session: Session) => session.state === SessionState.pending + ) protected async createSession(): Promise { const sessionId = generateUniqueFileName(generateTimestamp()) @@ -39,19 +41,18 @@ export class SessionController { const session: Session = { id: sessionId, - ready: true, - inUse: true, - consumed: false, - completed: false, + state: SessionState.pending, creationTimeStamp, deathTimeStamp, path: sessionFolder } const headersPath = path.join(session.path, 'stpsrv_header.txt') + await createFile(headersPath, 'content-type: text/html; charset=utf-8') this.sessions.push(session) + return session } @@ -66,6 +67,10 @@ export class SessionController { return session } + + public getSessionById(id: string) { + return this.sessions.find((session) => session.id === id) + } } export class SASSessionController extends SessionController { @@ -83,10 +88,7 @@ export class SASSessionController extends SessionController { const session: Session = { id: sessionId, - ready: false, - inUse: false, - consumed: false, - completed: false, + state: SessionState.initialising, creationTimeStamp, deathTimeStamp, path: sessionFolder @@ -144,13 +146,20 @@ ${autoExecContent}` process.sasLoc!.endsWith('sas.exe') ? session.path : '' ]) .then(() => { - session.completed = true + session.state = SessionState.completed + process.logger.info('session completed', session) }) .catch((err) => { - session.completed = true - session.crashed = err.toString() - process.logger.error('session crashed', session.id, session.crashed) + session.state = SessionState.failed + + session.failureReason = err.toString() + + process.logger.error( + 'session crashed', + session.id, + session.failureReason + ) }) // we have a triggered session - add to array @@ -167,15 +176,19 @@ ${autoExecContent}` const codeFilePath = path.join(session.path, 'code.sas') // TODO: don't wait forever - while ((await fileExists(codeFilePath)) && !session.crashed) {} + while ( + (await fileExists(codeFilePath)) && + session.state !== SessionState.failed + ) {} - if (session.crashed) + if (session.state === SessionState.failed) { process.logger.error( 'session crashed! while waiting to be ready', - session.crashed + session.failureReason ) - - session.ready = true + } else { + session.state = SessionState.pending + } } private async deleteSession(session: Session) { @@ -191,7 +204,7 @@ ${autoExecContent}` private scheduleSessionDestroy(session: Session) { setTimeout( async () => { - if (session.inUse) { + if (session.state === SessionState.running) { // adding 10 more minutes const newDeathTimeStamp = parseInt(session.deathTimeStamp) + 10 * 60 * 1000 @@ -202,7 +215,7 @@ ${autoExecContent}` const { expiresAfterMins } = session // delay session destroy if expiresAfterMins present - if (expiresAfterMins && !expiresAfterMins.used) { + if (expiresAfterMins && session.state !== SessionState.completed) { // calculate session death time using expiresAfterMins const newDeathTimeStamp = parseInt(session.deathTimeStamp) + diff --git a/api/src/controllers/internal/processProgram.ts b/api/src/controllers/internal/processProgram.ts index 206f429..0557c0f 100644 --- a/api/src/controllers/internal/processProgram.ts +++ b/api/src/controllers/internal/processProgram.ts @@ -3,7 +3,7 @@ import { WriteStream, createWriteStream } from 'fs' import { execFile } from 'child_process' import { once } from 'stream' import { createFile, moveFile } from '@sasjs/utils' -import { PreProgramVars, Session } from '../../types' +import { PreProgramVars, Session, SessionState } from '../../types' import { RunTimeType } from '../../utils' import { ExecutionVars, @@ -49,7 +49,7 @@ export const processProgram = async ( await moveFile(codePath + '.bkp', codePath) // we now need to poll the session status - while (!session.completed) { + while (session.state !== SessionState.completed) { await delay(50) } } else { @@ -114,13 +114,20 @@ export const processProgram = async ( await execFilePromise(executablePath, [codePath], writeStream) .then(() => { - session.completed = true + session.state = SessionState.completed + process.logger.info('session completed', session) }) .catch((err) => { - session.completed = true - session.crashed = err.toString() - process.logger.error('session crashed', session.id, session.crashed) + session.state = SessionState.failed + + session.failureReason = err.toString() + + process.logger.error( + 'session crashed', + session.id, + session.failureReason + ) }) // copy the code file to log and end write stream diff --git a/api/src/controllers/session.ts b/api/src/controllers/session.ts index f0cd049..e4cfdf5 100644 --- a/api/src/controllers/session.ts +++ b/api/src/controllers/session.ts @@ -1,6 +1,8 @@ import express from 'express' import { Request, Security, Route, Tags, Example, Get } from 'tsoa' import { UserResponse } from './user' +import { getSessionController } from './internal' +import { SessionState } from '../types' interface SessionResponse extends UserResponse { needsToUpdatePassword: boolean @@ -26,6 +28,18 @@ export class SessionController { ): Promise { return session(request) } + + /** + * The polling endpoint is currently implemented for single-server deployments only.
+ * Load balanced / grid topologies will be supported in a future release.
+ * If your site requires this, please reach out to SASjs Support. + * @summary Get session state (initialising, pending, running, completed, failed). + * @example completed + */ + @Get('/:sessionId/state') + public async sessionState(sessionId: string): Promise { + return sessionState(sessionId) + } } const session = (req: express.Request) => ({ @@ -35,3 +49,23 @@ const session = (req: express.Request) => ({ isAdmin: req.user!.isAdmin, needsToUpdatePassword: req.user!.needsToUpdatePassword }) + +const sessionState = (sessionId: string): SessionState => { + for (let runTime of process.runTimes) { + // get session controller for each available runTime + const sessionController = getSessionController(runTime) + + // get session by sessionId + const session = sessionController.getSessionById(sessionId) + + // return session state if session was found + if (session) { + return session.state + } + } + + throw { + code: 404, + message: `Session with ID '${sessionId}' was not found.` + } +} diff --git a/api/src/controllers/stp.ts b/api/src/controllers/stp.ts index 3e66e88..d1797ec 100644 --- a/api/src/controllers/stp.ts +++ b/api/src/controllers/stp.ts @@ -40,10 +40,12 @@ interface TriggerProgramPayload { interface TriggerProgramResponse { /** - * The SessionId is the name of the temporary folder used to store the outputs. - * For SAS, this would be the SASWORK folder. Can be used to poll program status. - * This session ID should be used to poll program status. - * @example "{ sessionId: '20241028074744-54132-1730101664824' }" + * `sessionId` is the ID of the session and the name of the temporary folder + * used to store program outputs.

+ * For SAS, this would be the location of the SASWORK folder.

+ * `sessionId` can be used to poll session state using the + * GET /SASjsApi/session/{sessionId}/state endpoint. + * @example "20241028074744-54132-1730101664824" */ sessionId: string } diff --git a/api/src/routes/api/session.ts b/api/src/routes/api/session.ts index 09d0d87..e866832 100644 --- a/api/src/routes/api/session.ts +++ b/api/src/routes/api/session.ts @@ -1,16 +1,37 @@ import express from 'express' import { SessionController } from '../../controllers' +import { sessionIdValidation } from '../../utils' const sessionRouter = express.Router() +const controller = new SessionController() + sessionRouter.get('/', async (req, res) => { - const controller = new SessionController() try { const response = await controller.session(req) + res.send(response) } catch (err: any) { res.status(403).send(err.toString()) } }) +sessionRouter.get('/:sessionId/state', async (req, res) => { + const { error, value: params } = sessionIdValidation(req.params) + if (error) return res.status(400).send(error.details[0].message) + + try { + const response = await controller.sessionState(params.sessionId) + + res.status(200) + res.send(response) + } catch (err: any) { + const statusCode = err.code + + delete err.code + + res.status(statusCode).send(err) + } +}) + export default sessionRouter diff --git a/api/src/routes/api/spec/stp.spec.ts b/api/src/routes/api/spec/stp.spec.ts index 1512378..1bfd79b 100644 --- a/api/src/routes/api/spec/stp.spec.ts +++ b/api/src/routes/api/spec/stp.spec.ts @@ -25,7 +25,7 @@ import { SASSessionController } from '../../../controllers/internal' import * as ProcessProgramModule from '../../../controllers/internal/processProgram' -import { Session } from '../../../types' +import { Session, SessionState } from '../../../types' const clientId = 'someclientID' @@ -493,10 +493,7 @@ const mockedGetSession = async () => { const session: Session = { id: sessionId, - ready: true, - inUse: true, - consumed: false, - completed: false, + state: SessionState.pending, creationTimeStamp, deathTimeStamp, path: sessionFolder diff --git a/api/src/types/Session.ts b/api/src/types/Session.ts index 4fa1cfb..31399e4 100644 --- a/api/src/types/Session.ts +++ b/api/src/types/Session.ts @@ -1,12 +1,16 @@ +export enum SessionState { + initialising = 'initialising', // session is initialising and not ready to be used yet + pending = 'pending', // session is ready to be used + running = 'running', // session is in use + completed = 'completed', // session is completed and can be destroyed + failed = 'failed' // session failed +} export interface Session { id: string - ready: boolean + state: SessionState creationTimeStamp: string deathTimeStamp: string path: string - inUse: boolean - consumed: boolean - completed: boolean - crashed?: string expiresAfterMins?: { mins: number; used: boolean } + failureReason?: string } diff --git a/api/src/utils/validation.ts b/api/src/utils/validation.ts index a4fe696..4ba5dab 100644 --- a/api/src/utils/validation.ts +++ b/api/src/utils/validation.ts @@ -201,3 +201,8 @@ export const triggerProgramValidation = (data: any): Joi.ValidationResult => }) .pattern(/^/, Joi.alternatives(Joi.string(), Joi.number())) .validate(data) + +export const sessionIdValidation = (data: any): Joi.ValidationResult => + Joi.object({ + sessionId: Joi.string().required() + }).validate(data)