1
0
mirror of https://github.com/sasjs/server.git synced 2025-12-10 19:34:34 +00:00

Merge pull request #379 from sasjs/issue-378

Issue 378
This commit is contained in:
Yury Shkoda
2024-10-31 15:51:13 +03:00
committed by GitHub
13 changed files with 178 additions and 66 deletions

View File

@@ -1,5 +1,3 @@
{ {
"cSpell.words": [ "cSpell.words": ["autoexec", "initialising"]
"autoexec"
]
} }

View File

@@ -113,8 +113,8 @@ components:
properties: properties:
sessionId: sessionId:
type: string 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." description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store code outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
example: '{ sessionId: ''20241028074744-54132-1730101664824'' }' example: 20241028074744-54132-1730101664824
required: required:
- sessionId - sessionId
type: object type: object
@@ -585,6 +585,14 @@ components:
- needsToUpdatePassword - needsToUpdatePassword
type: object type: object
additionalProperties: false additionalProperties: false
SessionState:
enum:
- initialising
- pending
- running
- completed
- failed
type: string
ExecutePostRequestPayload: ExecutePostRequestPayload:
properties: properties:
_program: _program:
@@ -597,8 +605,8 @@ components:
properties: properties:
sessionId: sessionId:
type: string 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." description: "`sessionId` is the ID of the session and the name of the temporary folder\nused to store program outputs.<br><br>\nFor SAS, this would be the location of the SASWORK folder.<br><br>\n`sessionId` can be used to poll session state using the\nGET /SASjsApi/session/{sessionId}/state endpoint."
example: '{ sessionId: ''20241028074744-54132-1730101664824'' }' example: 20241028074744-54132-1730101664824
required: required:
- sessionId - sessionId
type: object type: object
@@ -1841,6 +1849,30 @@ paths:
- -
bearerAuth: [] bearerAuth: []
parameters: [] 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.<br>\nLoad balanced / grid topologies will be supported in a future release.<br>\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: /SASjsApi/stp/execute:
get: get:
operationId: ExecuteGetRequest operationId: ExecuteGetRequest

View File

@@ -42,10 +42,12 @@ interface TriggerCodePayload {
interface TriggerCodeResponse { interface TriggerCodeResponse {
/** /**
* The SessionId is the name of the temporary folder used to store the outputs. * `sessionId` is the ID of the session and the name of the temporary folder
* For SAS, this would be the SASWORK folder. Can be used to poll job status. * used to store code outputs.<br><br>
* This session ID should be used to poll job status. * For SAS, this would be the location of the SASWORK folder.<br><br>
* @example "{ sessionId: '20241028074744-54132-1730101664824' }" * `sessionId` can be used to poll session state using the
* GET /SASjsApi/session/{sessionId}/state endpoint.
* @example "20241028074744-54132-1730101664824"
*/ */
sessionId: string sessionId: string
} }

View File

@@ -2,7 +2,7 @@ import path from 'path'
import fs from 'fs' import fs from 'fs'
import { getSessionController, processProgram } from './' import { getSessionController, processProgram } from './'
import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils' import { readFile, fileExists, createFile, readFileBinary } from '@sasjs/utils'
import { PreProgramVars, Session, TreeNode } from '../../types' import { PreProgramVars, Session, TreeNode, SessionState } from '../../types'
import { import {
extractHeaders, extractHeaders,
getFilesFolder, getFilesFolder,
@@ -75,8 +75,7 @@ export class ExecutionController {
const session = const session =
sessionByFileUpload ?? (await sessionController.getSession()) sessionByFileUpload ?? (await sessionController.getSession())
session.inUse = true session.state = SessionState.running
session.consumed = true
const logPath = path.join(session.path, 'log.log') const logPath = path.join(session.path, 'log.log')
const headersPath = path.join(session.path, 'stpsrv_header.txt') const headersPath = path.join(session.path, 'stpsrv_header.txt')
@@ -121,7 +120,7 @@ export class ExecutionController {
: '' : ''
// it should be deleted by scheduleSessionDestroy // it should be deleted by scheduleSessionDestroy
session.inUse = false session.state = SessionState.completed
const resultParts = [] const resultParts = []
@@ -145,7 +144,9 @@ export class ExecutionController {
return { return {
httpHeaders, httpHeaders,
result: result:
isDebugOn(vars) || session.crashed ? resultParts.join(`\n`) : webout isDebugOn(vars) || session.failureReason
? resultParts.join(`\n`)
: webout
} }
} }

View File

@@ -2,11 +2,8 @@ import { Request, RequestHandler } from 'express'
import multer from 'multer' import multer from 'multer'
import { uuidv4 } from '@sasjs/utils' import { uuidv4 } from '@sasjs/utils'
import { getSessionController } from '.' import { getSessionController } from '.'
import { import { executeProgramRawValidation, getRunTimeAndFilePath } from '../../utils'
executeProgramRawValidation, import { SessionState } from '../../types'
getRunTimeAndFilePath,
RunTimeType
} from '../../utils'
export class FileUploadController { export class FileUploadController {
private storage = multer.diskStorage({ private storage = multer.diskStorage({
@@ -56,9 +53,8 @@ export class FileUploadController {
} }
const session = await sessionController.getSession() const session = await sessionController.getSession()
// marking consumed true, so that it's not available // change session state to 'running', so that it's not available for any other request
// as readySession for any other request session.state = SessionState.running
session.consumed = true
req.sasjsSession = session req.sasjsSession = session

View File

@@ -1,5 +1,5 @@
import path from 'path' import path from 'path'
import { Session } from '../../types' import { Session, SessionState } from '../../types'
import { promisify } from 'util' import { promisify } from 'util'
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { import {
@@ -23,7 +23,9 @@ export class SessionController {
protected sessions: Session[] = [] protected sessions: Session[] = []
protected getReadySessions = (): 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<Session> { protected async createSession(): Promise<Session> {
const sessionId = generateUniqueFileName(generateTimestamp()) const sessionId = generateUniqueFileName(generateTimestamp())
@@ -39,19 +41,18 @@ export class SessionController {
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ready: true, state: SessionState.pending,
inUse: true,
consumed: false,
completed: false,
creationTimeStamp, creationTimeStamp,
deathTimeStamp, deathTimeStamp,
path: sessionFolder path: sessionFolder
} }
const headersPath = path.join(session.path, 'stpsrv_header.txt') const headersPath = path.join(session.path, 'stpsrv_header.txt')
await createFile(headersPath, 'content-type: text/html; charset=utf-8') await createFile(headersPath, 'content-type: text/html; charset=utf-8')
this.sessions.push(session) this.sessions.push(session)
return session return session
} }
@@ -66,6 +67,10 @@ export class SessionController {
return session return session
} }
public getSessionById(id: string) {
return this.sessions.find((session) => session.id === id)
}
} }
export class SASSessionController extends SessionController { export class SASSessionController extends SessionController {
@@ -83,10 +88,7 @@ export class SASSessionController extends SessionController {
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ready: false, state: SessionState.initialising,
inUse: false,
consumed: false,
completed: false,
creationTimeStamp, creationTimeStamp,
deathTimeStamp, deathTimeStamp,
path: sessionFolder path: sessionFolder
@@ -144,13 +146,20 @@ ${autoExecContent}`
process.sasLoc!.endsWith('sas.exe') ? session.path : '' process.sasLoc!.endsWith('sas.exe') ? session.path : ''
]) ])
.then(() => { .then(() => {
session.completed = true session.state = SessionState.completed
process.logger.info('session completed', session) process.logger.info('session completed', session)
}) })
.catch((err) => { .catch((err) => {
session.completed = true session.state = SessionState.failed
session.crashed = err.toString()
process.logger.error('session crashed', session.id, session.crashed) session.failureReason = err.toString()
process.logger.error(
'session crashed',
session.id,
session.failureReason
)
}) })
// we have a triggered session - add to array // we have a triggered session - add to array
@@ -167,15 +176,19 @@ ${autoExecContent}`
const codeFilePath = path.join(session.path, 'code.sas') const codeFilePath = path.join(session.path, 'code.sas')
// TODO: don't wait forever // 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( process.logger.error(
'session crashed! while waiting to be ready', 'session crashed! while waiting to be ready',
session.crashed session.failureReason
) )
} else {
session.ready = true session.state = SessionState.pending
}
} }
private async deleteSession(session: Session) { private async deleteSession(session: Session) {
@@ -191,7 +204,7 @@ ${autoExecContent}`
private scheduleSessionDestroy(session: Session) { private scheduleSessionDestroy(session: Session) {
setTimeout( setTimeout(
async () => { async () => {
if (session.inUse) { if (session.state === SessionState.running) {
// adding 10 more minutes // adding 10 more minutes
const newDeathTimeStamp = const newDeathTimeStamp =
parseInt(session.deathTimeStamp) + 10 * 60 * 1000 parseInt(session.deathTimeStamp) + 10 * 60 * 1000
@@ -202,7 +215,7 @@ ${autoExecContent}`
const { expiresAfterMins } = session const { expiresAfterMins } = session
// delay session destroy if expiresAfterMins present // delay session destroy if expiresAfterMins present
if (expiresAfterMins && !expiresAfterMins.used) { if (expiresAfterMins && session.state !== SessionState.completed) {
// calculate session death time using expiresAfterMins // calculate session death time using expiresAfterMins
const newDeathTimeStamp = const newDeathTimeStamp =
parseInt(session.deathTimeStamp) + parseInt(session.deathTimeStamp) +

View File

@@ -3,7 +3,7 @@ import { WriteStream, createWriteStream } from 'fs'
import { execFile } from 'child_process' import { execFile } from 'child_process'
import { once } from 'stream' import { once } from 'stream'
import { createFile, moveFile } from '@sasjs/utils' import { createFile, moveFile } from '@sasjs/utils'
import { PreProgramVars, Session } from '../../types' import { PreProgramVars, Session, SessionState } from '../../types'
import { RunTimeType } from '../../utils' import { RunTimeType } from '../../utils'
import { import {
ExecutionVars, ExecutionVars,
@@ -49,7 +49,7 @@ export const processProgram = async (
await moveFile(codePath + '.bkp', codePath) await moveFile(codePath + '.bkp', codePath)
// we now need to poll the session status // we now need to poll the session status
while (!session.completed) { while (session.state !== SessionState.completed) {
await delay(50) await delay(50)
} }
} else { } else {
@@ -114,13 +114,20 @@ export const processProgram = async (
await execFilePromise(executablePath, [codePath], writeStream) await execFilePromise(executablePath, [codePath], writeStream)
.then(() => { .then(() => {
session.completed = true session.state = SessionState.completed
process.logger.info('session completed', session) process.logger.info('session completed', session)
}) })
.catch((err) => { .catch((err) => {
session.completed = true session.state = SessionState.failed
session.crashed = err.toString()
process.logger.error('session crashed', session.id, session.crashed) session.failureReason = err.toString()
process.logger.error(
'session crashed',
session.id,
session.failureReason
)
}) })
// copy the code file to log and end write stream // copy the code file to log and end write stream

View File

@@ -1,6 +1,8 @@
import express from 'express' import express from 'express'
import { Request, Security, Route, Tags, Example, Get } from 'tsoa' import { Request, Security, Route, Tags, Example, Get } from 'tsoa'
import { UserResponse } from './user' import { UserResponse } from './user'
import { getSessionController } from './internal'
import { SessionState } from '../types'
interface SessionResponse extends UserResponse { interface SessionResponse extends UserResponse {
needsToUpdatePassword: boolean needsToUpdatePassword: boolean
@@ -26,6 +28,18 @@ export class SessionController {
): Promise<SessionResponse> { ): Promise<SessionResponse> {
return session(request) return session(request)
} }
/**
* The polling endpoint is currently implemented for single-server deployments only.<br>
* Load balanced / grid topologies will be supported in a future release.<br>
* 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<SessionState> {
return sessionState(sessionId)
}
} }
const session = (req: express.Request) => ({ const session = (req: express.Request) => ({
@@ -35,3 +49,23 @@ const session = (req: express.Request) => ({
isAdmin: req.user!.isAdmin, isAdmin: req.user!.isAdmin,
needsToUpdatePassword: req.user!.needsToUpdatePassword 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.`
}
}

View File

@@ -40,10 +40,12 @@ interface TriggerProgramPayload {
interface TriggerProgramResponse { interface TriggerProgramResponse {
/** /**
* The SessionId is the name of the temporary folder used to store the outputs. * `sessionId` is the ID of the session and the name of the temporary folder
* For SAS, this would be the SASWORK folder. Can be used to poll program status. * used to store program outputs.<br><br>
* This session ID should be used to poll program status. * For SAS, this would be the location of the SASWORK folder.<br><br>
* @example "{ sessionId: '20241028074744-54132-1730101664824' }" * `sessionId` can be used to poll session state using the
* GET /SASjsApi/session/{sessionId}/state endpoint.
* @example "20241028074744-54132-1730101664824"
*/ */
sessionId: string sessionId: string
} }

View File

@@ -1,16 +1,37 @@
import express from 'express' import express from 'express'
import { SessionController } from '../../controllers' import { SessionController } from '../../controllers'
import { sessionIdValidation } from '../../utils'
const sessionRouter = express.Router() const sessionRouter = express.Router()
const controller = new SessionController()
sessionRouter.get('/', async (req, res) => { sessionRouter.get('/', async (req, res) => {
const controller = new SessionController()
try { try {
const response = await controller.session(req) const response = await controller.session(req)
res.send(response) res.send(response)
} catch (err: any) { } catch (err: any) {
res.status(403).send(err.toString()) 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 export default sessionRouter

View File

@@ -25,7 +25,7 @@ import {
SASSessionController SASSessionController
} from '../../../controllers/internal' } from '../../../controllers/internal'
import * as ProcessProgramModule from '../../../controllers/internal/processProgram' import * as ProcessProgramModule from '../../../controllers/internal/processProgram'
import { Session } from '../../../types' import { Session, SessionState } from '../../../types'
const clientId = 'someclientID' const clientId = 'someclientID'
@@ -493,10 +493,7 @@ const mockedGetSession = async () => {
const session: Session = { const session: Session = {
id: sessionId, id: sessionId,
ready: true, state: SessionState.pending,
inUse: true,
consumed: false,
completed: false,
creationTimeStamp, creationTimeStamp,
deathTimeStamp, deathTimeStamp,
path: sessionFolder path: sessionFolder

View File

@@ -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 { export interface Session {
id: string id: string
ready: boolean state: SessionState
creationTimeStamp: string creationTimeStamp: string
deathTimeStamp: string deathTimeStamp: string
path: string path: string
inUse: boolean
consumed: boolean
completed: boolean
crashed?: string
expiresAfterMins?: { mins: number; used: boolean } expiresAfterMins?: { mins: number; used: boolean }
failureReason?: string
} }

View File

@@ -201,3 +201,8 @@ export const triggerProgramValidation = (data: any): Joi.ValidationResult =>
}) })
.pattern(/^/, Joi.alternatives(Joi.string(), Joi.number())) .pattern(/^/, Joi.alternatives(Joi.string(), Joi.number()))
.validate(data) .validate(data)
export const sessionIdValidation = (data: any): Joi.ValidationResult =>
Joi.object({
sessionId: Joi.string().required()
}).validate(data)