@noggin/elastic-noggin-sdk
Version:
Elastic Noggin SDK
167 lines (144 loc) • 6.27 kB
text/typescript
import { Observable, of } from "rxjs";
import { switchMap, delay, timeout, map, catchError, finalize } from "rxjs/operators";
import { Batch, Tip } from "./models/types";
import { EnoFactory } from "./EnoFactory";
import { IVars } from "./vars";
import { send } from "./send";
import { IEnSrvOptions } from "./IEnSrvOptions";
import { checkBatchForError } from "./error";
const DefaultRetryDelayMs = 5000;
const DefaultRetryAttempts = 20;
const DefaultTimeoutMs = 60000;
const OpTimeoutBufferMs = 3000; // The millis subtracted from the timeout to leave enough time to check the status
const MinOpTimeoutMs = 1000; // The minimum timeout in millis for the op/process
const MaxOpTimeoutMs = 20000; // The maximum timeout in millis for the op/process
const SessionInitTimeoutMs = 10000;
export interface IProcessOptions {
waitForFinish?: boolean;
inputVars?: IVars;
retryDelayMs?: number;
retryAttempts?: number;
timeoutMs?: number;
}
export interface IProcessResponse {
operationTip: Tip;
responseTip: Tip;
isFinished: boolean;
outputVars?: IVars;
}
// Starts a process on EnSrv
// If waitForFinish, then the observable will not return until the process is finished.
// Otherwise will return once the first response is received
export function startProcess(
tip: Tip,
enSrvOptions: IEnSrvOptions,
processOptions: IProcessOptions = {}
): Observable<IProcessResponse> {
const enoFactory = new EnoFactory("op/process", "security/policy/op");
enoFactory.setField("op/process/process", [tip]);
enoFactory.setField("op/process/inline-vars", [
JSON.stringify(processOptions.inputVars || {}),
]);
const processOp = enoFactory.makeEno();
let attemptsRemaining = processOptions.retryAttempts || DefaultRetryAttempts;
let checkResponse$: (
processResponse: IProcessResponse
) => Observable<IProcessResponse>;
checkResponse$ = (
processResponse: IProcessResponse
): Observable<IProcessResponse> => {
if (processResponse.isFinished || !processOptions.waitForFinish) {
return of(processResponse);
}
if (attemptsRemaining-- > 0) {
return getProcessStatus(processOp.tip, enSrvOptions).pipe(
delay(processOptions.retryDelayMs || DefaultRetryDelayMs),
switchMap(checkResponse$)
);
}
throw new Error("Too many attempts waiting for process to finish");
};
const timeoutMs = processOptions.timeoutMs || DefaultTimeoutMs;
const opProcessTimeoutMs = Math.min(MaxOpTimeoutMs, Math.max(MinOpTimeoutMs, timeoutMs-OpTimeoutBufferMs));
// Check if we already have a session token
const hasExistingSession = !!enSrvOptions.sessionToken;
// Set maintainInitialSessionToken to true so that `send` populates the initialSessionToken
const originalMaintainInitialSessionToken = enSrvOptions.maintainInitialSessionToken;
enSrvOptions.maintainInitialSessionToken = true;
// Create a function that returns the main process observable
const createProcessObservable = () => send([processOp], enSrvOptions).pipe(
// Timeout the op/process request slightly before the overall timeout to allow for checking the status
timeout(opProcessTimeoutMs),
// Convert the ENO batch to an IProcessResponse
map((batch) => makeProcessResponseFromBatch(processOp.tip, batch)),
// If we received any failure from starting the process or getting the process response out of the error
// then proactively go fetch the status to double-check
catchError(() => getProcessStatus(processOp.tip, enSrvOptions)),
// Poll the status if its not finished and we need to wait for it to finish
switchMap(checkResponse$),
// Abort the whole thing if we timeout
timeout(timeoutMs),
// Revert maintainInitialSessionToken back to its original value after process completes
finalize(() => {
enSrvOptions.maintainInitialSessionToken = originalMaintainInitialSessionToken;
})
);
// If we already have a session, skip session establishment and go directly to process
if (hasExistingSession) {
return createProcessObservable();
}
// Otherwise, establish the session by sending an empty batch first
return send([], enSrvOptions).pipe(
timeout(SessionInitTimeoutMs),
catchError(() => {
// Session initialization failed - ignore the error and proceed to process
// execution. The process execution could still succeed. However, if timeout
// error occurs, the op/process/status will fail since we could not establish
// an initial session.
return of(null);
}),
// Send the process operation to EnSrv. The enSrvOptions is now populated with the sessionToken
switchMap(() => createProcessObservable())
);
}
// Get the status of a process operation
export function getProcessStatus(
processOpTip: Tip,
enSrvOptions: IEnSrvOptions
): Observable<IProcessResponse> {
// Create a copy of options and use initialSessionToken if available
// 'op/process/status' requires the initial session the process was triggered with
const statusOptions = { ...enSrvOptions };
if (statusOptions.initialSessionToken) {
statusOptions.sessionToken = statusOptions.initialSessionToken;
}
const enoFactory = new EnoFactory("op/process/status", "security/policy/op");
enoFactory.setField("op/process/status:op-tip", [processOpTip]);
const statusOpEno = enoFactory.makeEno();
return send([statusOpEno], statusOptions).pipe(
map((batch) => makeProcessResponseFromBatch(processOpTip, batch))
);
}
// Handle the process response batch, and retry if necessary
function makeProcessResponseFromBatch(
processOpTip: Tip,
batch: Batch
): IProcessResponse {
checkBatchForError(batch);
for (let i = 0; i < batch.length; i++) {
const eno = batch[i];
if (
eno.getType() === "response/process" &&
eno.getFieldStringValue("response/process/op-tip") === processOpTip
) {
return {
operationTip: processOpTip,
responseTip: eno.tip,
outputVars: eno.getFieldJsonValue("response/process/inline-vars"),
isFinished: eno.getFieldBooleanValue("response/process/finished"),
};
}
}
// Something went wrong. There was no response/process in the response batch
throw new Error("error/message/server/internal");
}