diff --git a/.vscode/settings.json b/.vscode/settings.json index 9a770f0c..c15453ab 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 c9b00c61..f7929f0d 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 039ce3c0..f6aaa304 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 3004f562..c1ff1973 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 a06f5120..f2347721 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 3000c076..751d3063 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 206f4295..0557c0f5 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 f0cd049d..e4cfdf52 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 3e66e883..d1797ec6 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 09d0d873..e8668329 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 1512378b..1bfd79bf 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 4fa1cfba..31399e40 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 a4fe6962..4ba5dab1 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)