Skip to content

Commit

Permalink
Merge pull request #379 from sasjs/issue-378
Browse files Browse the repository at this point in the history
Issue 378
  • Loading branch information
YuryShkoda authored Oct 31, 2024
2 parents ea2ec97 + bc2cff1 commit d6e527e
Show file tree
Hide file tree
Showing 13 changed files with 178 additions and 66 deletions.
4 changes: 1 addition & 3 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
{
"cSpell.words": [
"autoexec"
]
"cSpell.words": ["autoexec", "initialising"]
}
40 changes: 36 additions & 4 deletions api/public/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.<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: 20241028074744-54132-1730101664824
required:
- sessionId
type: object
Expand Down Expand Up @@ -585,6 +585,14 @@ components:
- needsToUpdatePassword
type: object
additionalProperties: false
SessionState:
enum:
- initialising
- pending
- running
- completed
- failed
type: string
ExecutePostRequestPayload:
properties:
_program:
Expand All @@ -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.<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: 20241028074744-54132-1730101664824
required:
- sessionId
type: object
Expand Down Expand Up @@ -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.<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:
get:
operationId: ExecuteGetRequest
Expand Down
10 changes: 6 additions & 4 deletions api/src/controllers/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br><br>
* For SAS, this would be the location of the SASWORK folder.<br><br>
* `sessionId` can be used to poll session state using the
* GET /SASjsApi/session/{sessionId}/state endpoint.
* @example "20241028074744-54132-1730101664824"
*/
sessionId: string
}
Expand Down
11 changes: 6 additions & 5 deletions api/src/controllers/internal/Execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -121,7 +120,7 @@ export class ExecutionController {
: ''

// it should be deleted by scheduleSessionDestroy
session.inUse = false
session.state = SessionState.completed

const resultParts = []

Expand All @@ -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
}
}

Expand Down
12 changes: 4 additions & 8 deletions api/src/controllers/internal/FileUploadController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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

Expand Down
55 changes: 34 additions & 21 deletions api/src/controllers/internal/Session.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<Session> {
const sessionId = generateUniqueFileName(generateTimestamp())
Expand All @@ -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
}

Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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) +
Expand Down
19 changes: 13 additions & 6 deletions api/src/controllers/internal/processProgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions api/src/controllers/session.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,6 +28,18 @@ export class SessionController {
): Promise<SessionResponse> {
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) => ({
Expand All @@ -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.`
}
}
Loading

0 comments on commit d6e527e

Please sign in to comment.