diff --git a/CHANGELOG.md b/CHANGELOG.md index a8ae0f2..d80c649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +## [0.15.2] - 2024-12-30 + +- add some save state options, more docs to come + ## [0.15.1] - 2024-12-28 - accept options when deleting from storaga adapter diff --git a/docs/API.md b/docs/API.md index 6f8971d..e0ec524 100644 --- a/docs/API.md +++ b/docs/API.md @@ -7,7 +7,9 @@ Create BPMN engine middleware. Options: - `adapter`: Optional [storage adapter](#storage-adapter). Defaults to in-memory adapter based on [LRU cache](https://www.npmjs.com/package/lru-cache) -- `engineOptions`: Optional BPMN Engine [options](https://github.com/paed01/bpmn-engine/blob/master/docs/API.md) +- `engineOptions`: Optional BPMN Engine [options](https://github.com/paed01/bpmn-engine/blob/master/docs/API.md) with some optional properties + - `settings`: optional engine settings + - `saveEngineStateOptions`: optional object passed to adapter options - `maxRunning`: Optional number declaring number of max running engines per instance, passed to engines LRU Cache as max, defaults to 1000 - `engineCache`: Optional engine [LRU](https://www.npmjs.com/package/lru-cache) in-memory cache, defaults to `new LRUCache({ max: 1000, disposeAfter(engine) })` - `broker`: Optional [smqp](https://npmjs.com/package/smqp) broker, used for forwarding events from executing engines, events are shoveled on middleware name topic exchange diff --git a/example/processes/save-state.bpmn b/example/processes/save-state.bpmn index fea5efa..326b1bc 100644 --- a/example/processes/save-state.bpmn +++ b/example/processes/save-state.bpmn @@ -1,5 +1,5 @@ - + Flow_1nwcici @@ -21,6 +21,7 @@ ${30000} + ${true} saveState @@ -84,7 +85,15 @@ Flow_06oy5wv - + + + + + ${true} + + enableSaveState + + to-enable-save-state to-end @@ -101,9 +110,6 @@ - - - @@ -119,6 +125,9 @@ + + + diff --git a/package.json b/package.json index aa5bec5..3bf15f6 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bpmn-middleware", - "version": "0.15.1", + "version": "0.15.2", "description": "BPMN engine express middleware", "type": "module", "main": "./dist/main.cjs", @@ -76,7 +76,6 @@ "bpmn-engine": ">=15", "debug": "*", "express": ">=4", - "moddle-context-serializer": "*", "smqp": "*" }, "files": [ diff --git a/src/bpmn-middleware.js b/src/bpmn-middleware.js index 11e1378..0d0dc60 100644 --- a/src/bpmn-middleware.js +++ b/src/bpmn-middleware.js @@ -613,6 +613,9 @@ BpmnEngineMiddleware.prototype._resumeOptions = function resumeOptions(req, res, case 'autosaveenginestate': { options.autosaveEngineState = v === 'false' ? false : true; } + default: { + options[k] = v; + } } } @@ -669,12 +672,12 @@ BpmnEngineMiddleware.prototype._startProcessByCallActivity = async function star * @returns {Promise<{id:string}>} Started with id token */ BpmnEngineMiddleware.prototype._startDeployment = async function startDeployment(deploymentName, options) { - const deployment = await this.adapter.fetch(STORAGE_TYPE_DEPLOYMENT, deploymentName); + const deployment = await this.adapter.fetch(STORAGE_TYPE_DEPLOYMENT, deploymentName, options); if (!deployment) { throw new HttpError(`deployment with name ${deploymentName} does not exist`, 404, 'BPMN_DEPLOYMENT_NOT_FOUND'); } - const deploymentSource = await this.adapter.fetch(STORAGE_TYPE_FILE, deployment[0].path); + const deploymentSource = await this.adapter.fetch(STORAGE_TYPE_FILE, deployment[0].path, options); const { listener, variables, businessKey, caller, idleTimeout } = options; const token = randomUUID(); diff --git a/src/engines.js b/src/engines.js index 4b6231b..485cb8e 100644 --- a/src/engines.js +++ b/src/engines.js @@ -91,7 +91,7 @@ Engines.prototype.resume = async function resume(token, listener, options) { /** @type {MiddlewareEngine} */ let engine = engineCache.get(token); /** @type {import('types').MiddlewareEngineState} */ - const state = await this.adapter.fetch(STORAGE_TYPE_STATE, token); + const state = await this.adapter.fetch(STORAGE_TYPE_STATE, token, options); if (!state && !engine) { throw new HttpError(`Token ${token} not found`, 404); @@ -191,7 +191,6 @@ Engines.prototype.resumeAndCancelActivity = async function cancelActivity(token, * @param {string} token * @param {import('bpmn-engine').IListenerEmitter} listener * @param {import('types').SignalBody} body - * @param {import('types').ResumeOptions} [options] */ Engines.prototype.resuemAndFailActivity = async function failActivity(token, listener, body, options) { @@ -541,17 +540,21 @@ Engines.prototype._onStateMessage = async function onStateMessage(routingKey, me let saveState = autosaveEngineState; let saveStateIfExists = false; - let saveStateOptions; + let saveStateOptions = { ...engine.environment.settings.saveEngineStateOptions }; try { switch (routingKey) { case SAVE_STATE_ROUTINGKEY: { saveState = true; - saveStateOptions = message.content; + saveStateOptions = { ...saveStateOptions, ...message.content }; break; } case ENABLE_SAVE_STATE_ROUTINGKEY: { engine.environment.settings.autosaveEngineState = true; + engine.environment.settings.saveEngineStateOptions = Object.assign( + engine.environment.settings.saveEngineStateOptions || {}, + message.content + ); saveState = false; break; } diff --git a/src/memory-adapter.js b/src/memory-adapter.js index 79a62e7..f0e4662 100644 --- a/src/memory-adapter.js +++ b/src/memory-adapter.js @@ -70,7 +70,7 @@ MemoryAdapter.prototype.delete = function deleteByKey(type, key) { * @param {any} [options] Passed as fetch options to LRU cache */ MemoryAdapter.prototype.fetch = async function fetch(type, key, options) { - const value = await this.storage.fetch(`${type}:${key}`, options); + const value = await this.storage.fetch(`${type}:${key}`, { ...options, context: { ...options } }); if (!value) return value; const data = JSON.parse(value); diff --git a/test/features/resume-feature.js b/test/features/resume-feature.js new file mode 100644 index 0000000..2c56036 --- /dev/null +++ b/test/features/resume-feature.js @@ -0,0 +1,165 @@ +import request from 'supertest'; + +import * as testHelpers from '../helpers/test-helpers.js'; +import { MemoryAdapter, STORAGE_TYPE_STATE, StorageError } from '../../src/index.js'; + +const saveStateResource = testHelpers.getExampleResource('save-state.bpmn'); + +class MyStorageAdapter extends MemoryAdapter { + update(type, key, value, options) { + this.assertStateAndMandatoryProps(type, options); + return super.update(type, key, value, options); + } + upsert(type, key, value, options) { + this.assertStateAndMandatoryProps(type, options); + return super.upsert(type, key, value, options); + } + fetch(type, key, options) { + this.assertStateAndMandatoryProps(type, options); + return super.fetch(type, key, options); + } + assertStateAndMandatoryProps(type, options) { + if (type === STORAGE_TYPE_STATE && !options?.mandatoryProp) + throw new StorageError('cannot use adapter if mandatory prop is not present'); + } +} + +Feature('resume from state', () => { + Scenario('adapter with special needs', () => { + /** @type {MemoryAdapter} */ + let adapter; + /** @type {ReturnType} */ + let apps; + /** @type {ReturnType} */ + let appsWithAutosave; + before(() => { + adapter = new MyStorageAdapter(); + apps = testHelpers.horizontallyScaled(2, { autosaveEngineState: false, adapter }); + }); + after(() => { + apps?.stop(); + }); + + let deploymentName; + Given('a source matching scenario is deployed', async () => { + deploymentName = 'save-state-with-props'; + await testHelpers.createDeployment(apps.balance(), deploymentName, saveStateResource); + }); + + let startingApp; + let timer; + let token; + let response; + When('process is started with disabled auto save state', async () => { + startingApp = apps.balance(); + timer = testHelpers.waitForProcess(startingApp, deploymentName).timer(); + + response = await request(startingApp).post(`/rest/process-definition/${deploymentName}/start`).expect(201); + + token = response.body.id; + }); + + Then('timer is started', () => { + return timer; + }); + + let completed; + When('timer times out', () => { + const [engine] = apps.getRunningByToken(token); + completed = engine.waitFor('end'); + const timer = engine.environment.timers.executing.find((t) => t.owner.id === 'timeout'); + timer.callback(); + }); + + Then('run completes', () => { + return completed; + }); + + let signalApp; + When('attempting to signal message event without any query parameters', async () => { + signalApp = apps.balance(); + response = await request(signalApp) + .post('/rest/signal/' + token) + .send({ + id: 'Message_0', + }); + }); + + Then('bad request is returned since adapter has special needs', () => { + expect(response.statusCode, response.text).to.equal(502); + expect(response.body.message).to.match(/cannot use adapter/); + }); + + When('signalling message event with mandatory adapter props as query parameters', async () => { + completed = testHelpers.waitForProcess(signalApp, deploymentName).end(); + + response = await request(signalApp) + .post('/rest/signal/' + token) + .query({ mandatoryProp: true }) + .send({ + id: 'Message_0', + }); + }); + + Then('resumed run completes', () => { + return completed; + }); + + describe('auto-save is enabled', () => { + Given('a new middleware is added with auto save enabled and engine settings addressing save state', () => { + appsWithAutosave = testHelpers.horizontallyScaled(2, { + adapter, + autosaveEngineState: true, + engineOptions: { + settings: { + saveEngineStateOptions: { + mandatoryProp: true, + }, + }, + }, + }); + }); + + When('process is started', async () => { + startingApp = appsWithAutosave.balance(); + timer = testHelpers.waitForProcess(startingApp, deploymentName).timer(); + + response = await request(startingApp).post(`/rest/process-definition/${deploymentName}/start`).expect(201); + + token = response.body.id; + }); + + Then('timer is started', () => { + return timer; + }); + + When('timer times out', () => { + const [engine] = appsWithAutosave.getRunningByToken(token); + completed = engine.waitFor('end'); + const timer = engine.environment.timers.executing.find((t) => t.owner.id === 'timeout'); + timer.callback(); + }); + + Then('run completes by termination event', () => { + return completed; + }); + + When('signalling message event with mandatory adapter props as query parameters', async () => { + signalApp = appsWithAutosave.balance(); + + completed = testHelpers.waitForProcess(signalApp, deploymentName).end(); + + response = await request(signalApp) + .post('/rest/signal/' + token) + .query({ mandatoryProp: true }) + .send({ + id: 'Message_0', + }); + }); + + Then('bad request is returned', () => { + expect(response.statusCode, response.text).to.equal(400); + }); + }); + }); +});