From 2f0194476a838aeea33ee7712aaaccb7773ebc36 Mon Sep 17 00:00:00 2001 From: JulianWielga Date: Tue, 14 Jan 2025 12:39:12 +0100 Subject: [PATCH 01/10] max 6.x version of react-router (#7440) --- designer/client/package-lock.json | 56 +++++++++---------- designer/client/package.json | 2 +- designer/submodules/package-lock.json | 49 +++++++++------- .../packages/components/package.json | 2 +- 4 files changed, 59 insertions(+), 50 deletions(-) diff --git a/designer/client/package-lock.json b/designer/client/package-lock.json index 3dcb992fd53..97f763865a8 100644 --- a/designer/client/package-lock.json +++ b/designer/client/package-lock.json @@ -71,7 +71,7 @@ "react-notification-system-redux": "2.0.1", "react-redux": "7.2.6", "react-responsive-carousel": "3.2.23", - "react-router-dom": "6.8.1", + "react-router-dom": "6.28.1", "react-select": "5.2.2", "react-transition-group": "4.4.5", "react-treeview": "0.4.7", @@ -5022,11 +5022,11 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" }, "node_modules/@remix-run/router": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.2.tgz", - "integrity": "sha512-t54ONhl/h75X94SWsHGQ4G/ZrCEguKSRQr7DrjTciJXW0YU1QhlwYeycvK5JgkzlxmvrK7wq1NB/PLtHxoiDcA==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", "engines": { - "node": ">=14" + "node": ">=14.0.0" } }, "node_modules/@sideway/address": { @@ -23028,29 +23028,29 @@ } }, "node_modules/react-router": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.1.tgz", - "integrity": "sha512-Jgi8BzAJQ8MkPt8ipXnR73rnD7EmZ0HFFb7jdQU24TynGW1Ooqin2KVDN9voSC+7xhqbbCd2cjGUepb6RObnyg==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.1.tgz", + "integrity": "sha512-2omQTA3rkMljmrvvo6WtewGdVh45SpL9hGiCI9uUrwGGfNFDIvGK4gYJsKlJoNVi6AQZcopSCballL+QGOm7fA==", "dependencies": { - "@remix-run/router": "1.3.2" + "@remix-run/router": "1.21.0" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" }, "peerDependencies": { "react": ">=16.8" } }, "node_modules/react-router-dom": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.8.1.tgz", - "integrity": "sha512-67EXNfkQgf34P7+PSb6VlBuaacGhkKn3kpE51+P6zYSG2kiRoumXEL6e27zTa9+PGF2MNXbgIUHTVlleLbIcHQ==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.1.tgz", + "integrity": "sha512-YraE27C/RdjcZwl5UCqF/ffXnZDxpJdk9Q6jw38SZHjXs7NNdpViq2l2c7fO7+4uWaEfcwfGCv3RSg4e1By/fQ==", "dependencies": { - "@remix-run/router": "1.3.2", - "react-router": "6.8.1" + "@remix-run/router": "1.21.0", + "react-router": "6.28.1" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" }, "peerDependencies": { "react": ">=16.8", @@ -31967,9 +31967,9 @@ "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" }, "@remix-run/router": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.2.tgz", - "integrity": "sha512-t54ONhl/h75X94SWsHGQ4G/ZrCEguKSRQr7DrjTciJXW0YU1QhlwYeycvK5JgkzlxmvrK7wq1NB/PLtHxoiDcA==" + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==" }, "@sideway/address": { "version": "4.1.2", @@ -45519,20 +45519,20 @@ } }, "react-router": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.8.1.tgz", - "integrity": "sha512-Jgi8BzAJQ8MkPt8ipXnR73rnD7EmZ0HFFb7jdQU24TynGW1Ooqin2KVDN9voSC+7xhqbbCd2cjGUepb6RObnyg==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.1.tgz", + "integrity": "sha512-2omQTA3rkMljmrvvo6WtewGdVh45SpL9hGiCI9uUrwGGfNFDIvGK4gYJsKlJoNVi6AQZcopSCballL+QGOm7fA==", "requires": { - "@remix-run/router": "1.3.2" + "@remix-run/router": "1.21.0" } }, "react-router-dom": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.8.1.tgz", - "integrity": "sha512-67EXNfkQgf34P7+PSb6VlBuaacGhkKn3kpE51+P6zYSG2kiRoumXEL6e27zTa9+PGF2MNXbgIUHTVlleLbIcHQ==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.1.tgz", + "integrity": "sha512-YraE27C/RdjcZwl5UCqF/ffXnZDxpJdk9Q6jw38SZHjXs7NNdpViq2l2c7fO7+4uWaEfcwfGCv3RSg4e1By/fQ==", "requires": { - "@remix-run/router": "1.3.2", - "react-router": "6.8.1" + "@remix-run/router": "1.21.0", + "react-router": "6.28.1" } }, "react-scrollbars-custom": { diff --git a/designer/client/package.json b/designer/client/package.json index fe9d05ce7c8..0330944e1be 100644 --- a/designer/client/package.json +++ b/designer/client/package.json @@ -64,7 +64,7 @@ "react-notification-system-redux": "2.0.1", "react-redux": "7.2.6", "react-responsive-carousel": "3.2.23", - "react-router-dom": "6.8.1", + "react-router-dom": "6.28.1", "react-select": "5.2.2", "react-transition-group": "4.4.5", "react-treeview": "0.4.7", diff --git a/designer/submodules/package-lock.json b/designer/submodules/package-lock.json index 0b0d2aa98fc..17322fd556c 100644 --- a/designer/submodules/package-lock.json +++ b/designer/submodules/package-lock.json @@ -3313,10 +3313,11 @@ } }, "node_modules/@remix-run/router": { - "version": "1.3.2", - "license": "MIT", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", "engines": { - "node": ">=14" + "node": ">=14.0.0" } }, "node_modules/@sindresorhus/is": { @@ -14096,27 +14097,29 @@ } }, "node_modules/react-router": { - "version": "6.8.1", - "license": "MIT", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.1.tgz", + "integrity": "sha512-2omQTA3rkMljmrvvo6WtewGdVh45SpL9hGiCI9uUrwGGfNFDIvGK4gYJsKlJoNVi6AQZcopSCballL+QGOm7fA==", "dependencies": { - "@remix-run/router": "1.3.2" + "@remix-run/router": "1.21.0" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" }, "peerDependencies": { "react": ">=16.8" } }, "node_modules/react-router-dom": { - "version": "6.8.1", - "license": "MIT", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.1.tgz", + "integrity": "sha512-YraE27C/RdjcZwl5UCqF/ffXnZDxpJdk9Q6jw38SZHjXs7NNdpViq2l2c7fO7+4uWaEfcwfGCv3RSg4e1By/fQ==", "dependencies": { - "@remix-run/router": "1.3.2", - "react-router": "6.8.1" + "@remix-run/router": "1.21.0", + "react-router": "6.28.1" }, "engines": { - "node": ">=14" + "node": ">=14.0.0" }, "peerDependencies": { "react": ">=16.8", @@ -17260,7 +17263,7 @@ "react-highlight-words": "0.20.0", "react-i18next": "13.0.1", "react-query": "3.39.3", - "react-router-dom": "6.8.1", + "react-router-dom": "6.28.1", "react-truncate-list": "1.0.1", "react-virtualized": "9.22.5", "rooks": "7.9.0", @@ -19239,7 +19242,7 @@ "react-highlight-words": "0.20.0", "react-i18next": "13.0.1", "react-query": "3.39.3", - "react-router-dom": "6.8.1", + "react-router-dom": "6.28.1", "react-truncate-list": "1.0.1", "react-virtualized": "9.22.5", "rooks": "7.9.0", @@ -19285,7 +19288,9 @@ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" }, "@remix-run/router": { - "version": "1.3.2" + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==" }, "@sindresorhus/is": { "version": "0.7.0", @@ -26451,16 +26456,20 @@ "dev": true }, "react-router": { - "version": "6.8.1", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.1.tgz", + "integrity": "sha512-2omQTA3rkMljmrvvo6WtewGdVh45SpL9hGiCI9uUrwGGfNFDIvGK4gYJsKlJoNVi6AQZcopSCballL+QGOm7fA==", "requires": { - "@remix-run/router": "1.3.2" + "@remix-run/router": "1.21.0" } }, "react-router-dom": { - "version": "6.8.1", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.1.tgz", + "integrity": "sha512-YraE27C/RdjcZwl5UCqF/ffXnZDxpJdk9Q6jw38SZHjXs7NNdpViq2l2c7fO7+4uWaEfcwfGCv3RSg4e1By/fQ==", "requires": { - "@remix-run/router": "1.3.2", - "react-router": "6.8.1" + "@remix-run/router": "1.21.0", + "react-router": "6.28.1" } }, "react-transition-group": { diff --git a/designer/submodules/packages/components/package.json b/designer/submodules/packages/components/package.json index dbbd6073687..922650e8da8 100644 --- a/designer/submodules/packages/components/package.json +++ b/designer/submodules/packages/components/package.json @@ -28,7 +28,7 @@ "react-highlight-words": "0.20.0", "react-i18next": "13.0.1", "react-query": "3.39.3", - "react-router-dom": "6.8.1", + "react-router-dom": "6.28.1", "react-truncate-list": "1.0.1", "react-virtualized": "9.22.5", "rooks": "7.9.0", From 35c59c2c620bbb50f42bb332f8d9d1a651a2cdab Mon Sep 17 00:00:00 2001 From: Piotr Przybylski Date: Tue, 14 Jan 2025 14:15:24 +0100 Subject: [PATCH 02/10] Map int32 integer format in OpenAPI schema to the `Integer` type (#7438) --- .../openapi/parser/SwaggerParserTest.scala | 2 +- docs/Changelog.md | 1 + docs/integration/OpenAPI.md | 2 ++ .../engine/json/swagger/SwaggerTyped.scala | 2 ++ .../decode/FromJsonSchemaBasedDecoder.scala | 1 - .../json/swagger/SwaggerTypedTest.scala | 23 +++++++++++++++++++ 6 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 utils/json-utils/src/test/scala/pl/touk/nussknacker/engine/json/swagger/SwaggerTypedTest.scala diff --git a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/parser/SwaggerParserTest.scala b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/parser/SwaggerParserTest.scala index 0c9adf8bd94..138b26ed5eb 100644 --- a/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/parser/SwaggerParserTest.scala +++ b/components/openapi/src/test/scala/pl/touk/nussknacker/openapi/parser/SwaggerParserTest.scala @@ -21,7 +21,7 @@ class SwaggerParserTest extends AnyFunSuite with BaseOpenAPITest with Matchers { openApi.name.value shouldBe "getCollectionDataUsingGET" openApi.parameters shouldBe List( - UriParameter("accountNo", SwaggerLong), + UriParameter("accountNo", SwaggerInteger), QueryParameter("pretty", SwaggerBool), HeaderParameter("System-Name", SwaggerString), HeaderParameter("System-User-Name", SwaggerString), diff --git a/docs/Changelog.md b/docs/Changelog.md index f881d7e065e..67927213ae4 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -47,6 +47,7 @@ * [#7354](https://github.com/TouK/nussknacker/pull/7354) Reduce response payload size when fetching scenarios for scenarios tab by removing unused fields and `null` attributes. * [#7404](https://github.com/TouK/nussknacker/pull/7404) Fix spel evaluation error when using conversion extensions methods or array.get extension method * [#7420](https://github.com/TouK/nussknacker/pull/7420) Add toInteger and toIntegerOrNull conversions. Also add canBeInteger extension +* [#7438](https://github.com/TouK/nussknacker/pull/7438) Map int32 integer format in OpenAPI schema to the `Integer` type ## 1.18 diff --git a/docs/integration/OpenAPI.md b/docs/integration/OpenAPI.md index 5cfe8442ef5..5f79f6b2833 100644 --- a/docs/integration/OpenAPI.md +++ b/docs/integration/OpenAPI.md @@ -29,6 +29,8 @@ Table below describes data types that OpenAPI integration handles: | string | | String | | string | date-time | LocalDateTime | | integer | | Long | +| integer | int32 | Integer | +| integer | int64 | Long | | number | | BigDecimal | | number | double | Double | | number | float | Double | diff --git a/utils/json-utils/src/main/scala/pl/touk/nussknacker/engine/json/swagger/SwaggerTyped.scala b/utils/json-utils/src/main/scala/pl/touk/nussknacker/engine/json/swagger/SwaggerTyped.scala index 8c8803d4a46..6d91c2c2cb0 100644 --- a/utils/json-utils/src/main/scala/pl/touk/nussknacker/engine/json/swagger/SwaggerTyped.scala +++ b/utils/json-utils/src/main/scala/pl/touk/nussknacker/engine/json/swagger/SwaggerTyped.scala @@ -131,6 +131,8 @@ object SwaggerTyped { case (Some("string"), Some("date")) => SwaggerDate case (Some("string"), Some("time")) => SwaggerTime case (Some("string"), _) => SwaggerString + case (Some("integer"), Some("int32")) => SwaggerInteger + case (Some("integer"), Some("int64")) => SwaggerLong case (Some("integer"), _) => inferredIntType( schema.getMinimum, diff --git a/utils/json-utils/src/main/scala/pl/touk/nussknacker/engine/json/swagger/decode/FromJsonSchemaBasedDecoder.scala b/utils/json-utils/src/main/scala/pl/touk/nussknacker/engine/json/swagger/decode/FromJsonSchemaBasedDecoder.scala index 0b241b40564..cfe524b9b21 100644 --- a/utils/json-utils/src/main/scala/pl/touk/nussknacker/engine/json/swagger/decode/FromJsonSchemaBasedDecoder.scala +++ b/utils/json-utils/src/main/scala/pl/touk/nussknacker/engine/json/swagger/decode/FromJsonSchemaBasedDecoder.scala @@ -71,7 +71,6 @@ object FromJsonSchemaBasedDecoder { case SwaggerInteger => extract[JsonNumber](_.asNumber, n => int2Integer(n.toDouble.toInt)) case SwaggerLong => - // FIXME: to ok? extract[JsonNumber](_.asNumber, n => long2Long(n.toDouble.toLong)) case SwaggerBigInteger => extract[JsonNumber](_.asNumber, _.toBigInt.map(_.bigInteger).orNull) diff --git a/utils/json-utils/src/test/scala/pl/touk/nussknacker/engine/json/swagger/SwaggerTypedTest.scala b/utils/json-utils/src/test/scala/pl/touk/nussknacker/engine/json/swagger/SwaggerTypedTest.scala new file mode 100644 index 00000000000..8bf849638f0 --- /dev/null +++ b/utils/json-utils/src/test/scala/pl/touk/nussknacker/engine/json/swagger/SwaggerTypedTest.scala @@ -0,0 +1,23 @@ +package pl.touk.nussknacker.engine.json.swagger + +import io.swagger.v3.oas.models.media.{IntegerSchema, ObjectSchema, Schema} +import org.scalatest.funsuite.AnyFunSuite +import pl.touk.nussknacker.engine.api.typed.typing.Typed +import pl.touk.nussknacker.test.ProcessUtils.convertToAnyShouldWrapper + +class SwaggerTypedTest extends AnyFunSuite { + + test("should map int32 and int64 integer formats") { + val schema = new ObjectSchema() + .addProperty("int32", new IntegerSchema().format("int32")) + .addProperty("int64", new IntegerSchema().format("int64")) + .addProperty("integer", new IntegerSchema().format(null)) + + val result = SwaggerTyped(schema, Map.empty).typingResult + + result shouldBe Typed.record( + Map("int32" -> Typed[Integer], "int64" -> Typed[java.lang.Long], "integer" -> Typed[java.lang.Long]) + ) + } + +} From 8675c1750520930e6c6df057b51261a0920d6465 Mon Sep 17 00:00:00 2001 From: Dawid Poliszak Date: Tue, 14 Jan 2025 14:32:01 +0100 Subject: [PATCH 03/10] react router fix (#7444) --- designer/client/src/components/RouteLeavingGuard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/designer/client/src/components/RouteLeavingGuard.tsx b/designer/client/src/components/RouteLeavingGuard.tsx index 5e5298630ea..5c8a64c8bf8 100644 --- a/designer/client/src/components/RouteLeavingGuard.tsx +++ b/designer/client/src/components/RouteLeavingGuard.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect } from "react"; import { unsavedProcessChanges } from "../common/DialogMessages"; import { useWindows } from "../windowManager"; -import { unstable_useBlocker as useBlocker } from "react-router-dom"; +import { useBlocker } from "react-router-dom"; export function useRouteLeavingGuard(when: boolean) { const { confirm } = useWindows(); From 16d7ca550c767a7204009cc64a64a0569d305c1e Mon Sep 17 00:00:00 2001 From: Filip Michalski Date: Tue, 14 Jan 2025 16:36:06 +0100 Subject: [PATCH 04/10] Sticky notes (#7277) * Create StickyNotes DB entity * Add PoC httpApiService for StickyNotes - add slick migration * Add put and delete endpoints, use value classes for ID in stickyNotes model * Add StickyNote PoC to FE * Resize and update StickyNotes * Handle note removal * Remove some unused fragments * Update openApi definition * Resize stickyNote without visual-lag * Disable stickyNotes when scenario is not saved * Show/hide tools on graph actions * Edit stickyNote on graph * Fix some suggestions made by rabbit * Update openApi definitions * Add white characters to textarea in stickyNote markdown editor * Allow focus to stay in markdown editor * Allow switch viewer to editor witgh left mouse click * Add stickyNotes length and count validation, add stickyNotes configuration, fix error method (or did i?) * Remove node specific code from StickyNotePreview, update openApi defs * Add some fixes, improve types, add max width and height for stickyNote * Add STICKY_NOTE_CONSTRAINTS with config values * Reuse common code, restore default color, remove duplicated update method * Restore CSS class, remove unused imports * Remove stickyNotePanel, add stickyNote to creatos panel * Update cypress test * Add Changelog and migrationGuide entries * Updated snapshots (#7275) Co-authored-by: philemone <13632953+philemone@users.noreply.github.com> * remove space * Remove DOMpurify and use XSS instead * Remove StickyNotePreview from ComponentPreview * Rename stickyNotes panel to sticky notes * Fix markdown preview * Change shape of stickyNote, fix 'changed' detection for notes * Add notifications to graph, prevent from making changes to old stickyNotes versions * Revert fix in error notification msg * Fix warn messages * Add strict type for color #xxxxxx, remove unnecessary generated noteId, add more optimistic tests * Update pointer, add cypress tests, remove todo from uncomplete method, remove unused dompurify from package * Add regex in sticky note tests for note id * Change remove tool color and margins, update cypress tests * update cypress tests * Add maxDiffThreshold to flaky cypress test --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: philemone <13632953+philemone@users.noreply.github.com> --- ...ow collapse (persist) and filtering #0.png | Bin 12310 -> 14293 bytes ... to note and display it as markdown #0.png | Bin 0 -> 72354 bytes ...es should allow to drag sticky note #0.png | Bin 0 -> 69916 bytes ...cky note when scenario is not saved #0.png | Bin 0 -> 71149 bytes designer/client/cypress/e2e/components.cy.ts | 2 +- .../client/cypress/e2e/creatorToolbar.cy.ts | 1 + designer/client/cypress/e2e/stickyNotes.cy.ts | 65 +++ .../client/cypress/fixtures/stickyNotes.json | 58 ++ designer/client/src/actions/actionTypes.ts | 2 + .../client/src/actions/nk/assignSettings.ts | 7 + designer/client/src/actions/nk/process.ts | 43 ++ .../src/actions/notificationActions.tsx | 8 + .../src/assets/json/nodeAttributes.json | 3 + designer/client/src/common/StickyNote.ts | 16 + .../src/components/ComponentDragPreview.tsx | 35 +- .../src/components/ComponentPreview.tsx | 1 + .../src/components/StickyNotePreview.tsx | 62 +++ .../components/graph/EspNode/stickyNote.ts | 128 +++++ .../graph/EspNode/stickyNoteElements.ts | 137 +++++ .../client/src/components/graph/Graph.tsx | 166 +++++- .../GraphPartialsInTS/applyCellChanges.ts | 21 +- .../graph/GraphPartialsInTS/cellUtils.ts | 13 + .../src/components/graph/GraphWrapped.tsx | 8 +- .../graph/NodeDescriptionPopover.tsx | 2 + .../src/components/graph/ProcessGraph.tsx | 29 +- .../src/components/graph/StickyNoteElement.ts | 52 ++ .../src/components/graph/fragmentGraph.tsx | 34 +- .../components/graph/graphStyledWrapper.ts | 37 +- .../graph/node-modal/node/FragmentContent.tsx | 4 +- designer/client/src/components/graph/types.ts | 12 + .../src/components/graph/utils/graphUtils.ts | 3 +- .../toolbars/creator/ComponentIcon.tsx | 6 + .../toolbars/creator/StickyNoteComponent.tsx | 19 + .../components/toolbars/creator/ToolBox.tsx | 26 +- .../creator/ToolboxComponentGroup.tsx | 8 +- .../client/src/containers/theme/helpers.ts | 10 + .../client/src/containers/theme/nuTheme.tsx | 1 + designer/client/src/http/HttpService.ts | 60 ++- designer/client/src/reducers/graph/reducer.ts | 15 + designer/client/src/reducers/graph/types.ts | 2 + designer/client/src/reducers/graph/utils.ts | 40 ++ .../client/src/reducers/selectors/graph.ts | 5 + .../client/src/reducers/selectors/settings.ts | 1 + designer/client/src/types/component.ts | 1 + designer/client/src/types/node.ts | 3 +- designer/client/src/types/stickyNote.ts | 4 + .../main/resources/defaultDesignerConfig.conf | 6 + .../static/assets/components/StickyNote.svg | 1 + .../V1_060__CreateStickyNotesDefinition.scala | 89 ++++ .../hsql/V1_060__CreateStickyNotes.scala | 8 + .../postgres/V1_060__CreateStickyNotes.scala | 8 + .../ui/api/SettingsResources.scala | 5 +- .../ui/api/StickyNotesApiHttpService.scala | 230 ++++++++ .../description/StickyNotesApiEndpoints.scala | 219 ++++++++ .../ui/api/description/stickynotes/Dtos.scala | 119 +++++ .../stickynotes/StickyNoteEvent.scala | 14 + .../ui/config/FeatureTogglesConfig.scala | 11 +- .../pl/touk/nussknacker/ui/db/NuTables.scala | 3 +- .../db/entity/StickyNotesEntityFactory.scala | 112 ++++ .../stickynotes/DbStickyNotesRepository.scala | 174 ++++++ .../stickynotes/StickyNotesRepository.scala | 55 ++ .../server/AkkaHttpBasedRouteProvider.scala | 12 + .../resources/config/common-designer.conf | 6 + .../WithSimplifiedConfigScenarioHelper.scala | 10 + .../test/utils/domain/ScenarioHelper.scala | 61 ++- ...tickyNotesApiHttpServiceBusinessSpec.scala | 165 ++++++ docs-internal/api/nu-designer-openapi.yaml | 499 ++++++++++++++++++ docs/Changelog.md | 4 + docs/MigrationGuide.md | 9 + 69 files changed, 2898 insertions(+), 72 deletions(-) create mode 100644 designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should add text to note and display it as markdown #0.png create mode 100644 designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should allow to drag sticky note #0.png create mode 100644 designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should disable sticky note when scenario is not saved #0.png create mode 100644 designer/client/cypress/e2e/stickyNotes.cy.ts create mode 100644 designer/client/cypress/fixtures/stickyNotes.json create mode 100644 designer/client/src/common/StickyNote.ts create mode 100644 designer/client/src/components/StickyNotePreview.tsx create mode 100644 designer/client/src/components/graph/EspNode/stickyNote.ts create mode 100644 designer/client/src/components/graph/EspNode/stickyNoteElements.ts create mode 100644 designer/client/src/components/graph/StickyNoteElement.ts create mode 100644 designer/client/src/components/toolbars/creator/StickyNoteComponent.tsx create mode 100644 designer/client/src/types/stickyNote.ts create mode 100644 designer/server/src/main/resources/web/static/assets/components/StickyNote.svg create mode 100644 designer/server/src/main/scala/db/migration/V1_060__CreateStickyNotesDefinition.scala create mode 100644 designer/server/src/main/scala/db/migration/hsql/V1_060__CreateStickyNotes.scala create mode 100644 designer/server/src/main/scala/db/migration/postgres/V1_060__CreateStickyNotes.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpService.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/StickyNotesApiEndpoints.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/Dtos.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/StickyNoteEvent.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/StickyNotesEntityFactory.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/DbStickyNotesRepository.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/StickyNotesRepository.scala create mode 100644 designer/server/src/test/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpServiceBusinessSpec.scala diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Creator toolbar should allow collapse (persist) and filtering #0.png index c5c1c96a2cec9af3367f969733b8e76f64f37aea..4766a9dfb92bee3889a44374b304779f43063756 100644 GIT binary patch literal 14293 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 jR_2ea60wFD`N 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 V2%L{9M-TEV$*am$$UJ!bzW^Thqy7K@ diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should add text to note and display it as markdown #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should add text to note and display it as markdown #0.png new file mode 100644 index 0000000000000000000000000000000000000000..7cf3a281fd85c4416ea13818ffcfe552285b54b5 GIT binary patch literal 72354 zcmbq)1yCGayCwv8x50wD`{3?_TOfFFcL)K3dlFm*cM?1}!QFxe2<}dBmmQLP-@Uv0 z->R+JDynC?d%Dj#uRibd9wJmhvS`QvWGE;oGbG>{K` zH)&lr8FdX6Ep>pvV%}h~WFzQymc-6sj0I2WAn|%RS75VC;f|~6Kl-gdTdhm;~e4{SE(2A8v z)ODvNBlwaAdtAT1LMfZ~a5)SbekLs~+(m z`Igl=4Iz0`#_;*E7U!gDu*B}A7>8zyrS5iRQyrF2ZJB&&UO*v%FhSxgcE=w!r_djk zf{3z!;rR|nV^$Q7-+e@M-+q(wiAfmYVPg^XmHkuy`(a^r<+NGoi}0N9PG1VS&agX<&JPbQgq@Ug zDBU@;XZZdi>Y8d^`Ng!fwY$B%86-&|1T=YR@{j%-)CjLLr?Th0&KoUnF1qS5^P6zq zeWy3{+SDtIX^AmTMA3jjytK~|x_e3UgVrXpzUsJ#&)Yu>ME80UnpAmm{Dzmu8}Z6( z>$D?|Wg|gbL#-uwQ&Fet=wmEh5g`=YS6Iz z!Bivr160;Ko&;-+%ymzMsQ#gcP|{{shH4kJ&bOh)Le;4cz#>h*u26+oyf&<{*@Nowk-4Jj^U7Y8P+}5!COSUV_yPB)Y z^>iDz-=^Drv2mHNe_ik2m<5vVk?kL0gRByydb5<)ulFFrwkgt{|7kVh>A_57K_h0g z$A-dkdVcu2kL6r>M{4$gBrJ4vlDgUOkEN4Ic~G zd}MPd2!p-ah~GB4{?mqYIEJf!0|{fFl>su|7=O#|cjm{pq<|vZ9B~9j&(k{s49sM( z*mAJQg6JxP3_-oDG$0~Ns&;tT%oABZKF()}%LS^9XYJUx*Yi2oh#^9e`*-`m_ z=jHT?-MFxXALDakBeuc%9g1h(`+FbvUEg}S24+=YP=#TvI_~qjG)6zr)!d(W83+)> zy#DpwLR=ela;qq(%qy`+0LVwP{3!HvD%Wq&-eRxKs&$ZJ#Kpw%p^@!NLBM`+TK{>y ztj5#jXJ)pwJB{@eUQsmrp3lsqlS5u8bb3`sJf%pvSm#V}h2+FDMyRH|;JD%6zRAjqigysAX<(ew^{rf(X0%VeyEI zCK+BeM`AGLdbxEP=W2qgf6XiAI2w^PO42xf7k6WgcW2wL*76g&v|N}MOv!fm)^N3L zc3ox3y5Cy5odN{dF>-h=$hh%2YPy~B*%vfj$rZ*lh3q&yN-c7JENQz}p?%;d!Araz ze;SC=kl}bak(J#}4}V`w&~XVIB>pSE2#O$hr@FVlh}y4m-n?Odx5XsHbB5=4amlMt zt00mp`k~08wNry@yNX#+)~zzJT97Ghk;a2Hj7BtUQ7=Sy2bs}R^bG!2EC+>yFXQVn z58^1DV0MNJj;-j+02vhTW}PZ8Ngi3ftJBpcSlXCt6+h>!?I%4VfUkb9JVAX%wU{1KP;ClQsyh%6Zf!@@b$IplPB`81f- z)H~_9F(mU$RaxP%<8||Av0d_y(Fi9PWKHHako+~tg?F~_p0h~4y!ZY?*kbX(c0465D9|b z&lvgQJ36u%K0RPxK5%9IVmT1m`UdW-n?t!S4%L|AFC0+`s@)$uA{|&178)x>nM=tO zmx)a@(^QgFPvxdWiYK2f9kJ7YpkS)mS8Jl|UzEOa_uQwmE%N7GqmhN_Dx?swLJ`Cd#`M#R!8=7$G-{lzQ#(c<-74xN&y(Ma7^Oct)aGuI!8 zl^*6Rs$0VIgw~JDCQ>@6!`_NX;x!6FVAh^5AmF3Pr0&PKuFhPq-eud)?^Z?3w!%@# zj(uNXY)JkqmSo(o{dqX}t?^f_Pv1fs?-vuri4X^jaxRc~fI1c_wDfR2u zh-&RE40Z8*#aIHP6ij%ii+px%rA3>r=oaftXQB&Om+8h?Eis#G%N$N?P6gD4^er#M z#t_H9PY3VeD4mm15oL^Z#`=>$EbAF4hBFQA*yL+SYw_^ZUTA8~sHZ9qw$5=lWC;ne|bXk}DS1*=Q+qbWkscnJzt@cP|(7z%T;acZ-9AMBhV!loD%a!d*=66e5+&KBjF+L-37$ zVOu@QjJUPZ;jdBoOb*m&Ci5HHiqok`67Er4PA8HYbt;BYgTXVlwyrfc?pu}A;cos! z6lCJzuMsCx6|V=+E&YeUweAZdt5~7eE@UF-&3u8c6}LD}2H=>PBB<>nrv9C3kj(h zS-+uaJ_h)aBV~fIc~kUM(0jS);st89IV`pCd&SpNIG7V~49r5~04-7V7NZtx2dLHW z$W4DF%%7~2bRL=Y)&!jl4wxSep?Gl!V^plhr(PJt8e6z(vlK$h2RnfQ-`l17my&|e zJmdP5E!5pu$iLMG?6MV|sVP&hXDZXo1?5SEDjDC>le=&|wx6-FQRY#8ni7A%u!FiV zrFmPZyxCLruJT>dK$__v(A=>0^CvfO(ZiM`PX=`p66wNYc*jd>%VIuvv{1x5sIXI1S{c}k9HVngbCQ< zpl~U2pzW!^_E~G$jV^%1^J2B*ReD1ug!0xRvL#3Gw}!!e3)43VJg=%Y98DxM70i99 z>8uNU<61)&keSdtGPtDK?W9nXR3`LVhm`cBiROqW^^!I_+7UF4H(mrvp3Ic?k8+q< ze>Gi78m-8DJ-8(^y1x?2V^?7%UZYbb>`MHmgVyJ6f+Em_%CyoqZuIoE#USHg|5D0m zH8TdhNJ=0bsapmw&cO3jk}wN96d7U462+^_jh)W67$Gd<%Ai%&cnmP|ZtIwo(3_t{D%Dxb zqbh2?M@McT8xAjVX0EFhrIpx@$v=JB!7=p@Ph~;bj+mQTpdLi7Ead6)p*Fp z)^?TcHpnA+$1O4(smIF;XAT0LH;%30NPP(j@xuLwb(d6Dxn&IOwIwv^^WyOoVd$(q z&_=Afqt>HoLxl)ce(g8}!-kpenMlo-x+tJr@Qx~@_2Amk@U=y1xVc;$M#dpLtSz_79Zpl7$N7anp}HTev@M`F>aWK`PmBN=8D9Q^Q#b~cV!x>5k* zu&V7LA>FXXLSX6gQTbOU&;_rA`mT*5k-J4Ln12;H0jSD(nJ5?&pzWtEhCM(f>}GXn z1(q0im#zSEB@;5;YGkXGgix^1VDClW2ErUo^XzMpl=lqKzD?r_rvZfh121(Dd{V;M zWw7r~iSu0mc~~`B7{xhKu=)bJntcq~==jqU_#Uy!X@yOGopF4`_xzUW0Hl;_WGYnuAw_JjSO~v0Y4k{@ zDFJJYp8#)+eyK7cgxF@BKO@73^j@I1e^+x$YjmaF)6>K=vL`c z*9a>pL|0TMy$)F$Xe_=CkzT1to%`lZIz+&-(o>t50)Np5tBd@3%%?kkrGvpFLWrLo zUi3=}(?KxojjGWnWTJuwQ_01Zv-ILfiXA^P<- zUF7}e;j>-hIA}97Wnybg%PVoPHzeOFlP0ABnR(o&h+&X80(&{iSF6r1Hxr7zOoeS3 zM83Ed0*CQDB-Tt%)^Wdgdr{gIq!9C$+F9ank=46iW#eLh72@6Acbh;@HsOHBH2w0A zFk)hj8rZA?3_}`!h6OjMf{-XwXL1G_tJ6ygjw0O0Vt z=c35Sjo!~Uon_y(&N?DGi!)`T5{h!|$YL+WD;m(ZyWub!NaM%NMFyhgWrosNGcFVs zOfBi6;&1_5tQFxLme2R=B44CsHH2~a@^ej{-CMM>3LT~N~Z)zLA1Gy4Qdja ze*X`FLfR<%Fe;)%z=x#TD1%trg@K>U7@^8ULzNAx5}is5!du(JX`FG7es1(;>+oC@ zRK1*4Z4u#>h_}Mv-b&*P-qr?rQynwReg(A>sQ^lm(W(WC=Y za~P{CR0uUc*gd^P0Hg}M99P1Gq88jWp$Mi^HVm_=9ShMC+^kfXg)!; ziyyp_SGYCXhh7M%HM<2?_ldO2)1;@|kwueprRjCSaM;x19t;~h$WTBabfO~_muS-0 zR18e(uNvUm(8i&*!uPJlv)%f?I`TP6S~wSA?6k}sbq58rlAIMt=j+nsiMPAT%Pc_F zCReATmCP)OQw)=-8weL+XS#QWw8XajgVo+}C_@iPjM$>${S2 zPA|*F8Tqhjc1Q0rAPfURW&PJN;r8~M15wpy4LKLkTtsXb#7+q`Osw5Wot;)>ZI}u@ zHN)YOvr31TV{+O7P~d$>&S&b4R|ijsPhs209V|vs_SvaerE2THLRi}8`oH0HnC6Y| zwxf@(+uzk=PV zF`WeBxO5W&A%E5bg2HnNUH&y+^cmF`#M+fj`S@K3Y+Kw#`*qjk~$vWFZ=RT676SuSc3OqG#HfqmDcvV~Z^ zZG9me4evJ!sEvwCCf+wthTew`=&hIVOp5%Y-Q9edFHhrjQfuZ0iJl8-e^~oN{OGGC z*)}pVt4(k7^8S^{T>Myd$wonll|$1?L<5sLnMD+gy0(x>eSd8g)RR-ZNB=%*Y1{?c zy0^h=46s~yrv%_i*>J+JDyH<@?qk-^Q2DnzP|m|RlU-;*oaML_Te#~R^+L319~~ll zS3lZbVS%)8u-jIPWiLLsvzu;`(CC~I#q%K|f+sJ9k1I|k>-C^Tb8N4kCeC_hMtl3- z1+yOuLezM7F;dAeKxasU=~N)Y@?6d^4f}(4k0A4RYv{|G!(+Knw8#LhHzatAS@RFW zUq-539DbKZZxpwx8ak5w)@ixiJ7GwCO<3R>CLPyslwugoH{YW4+aFVyXfonh7LE%E zW-P|cr=H~}T2~Z^-!d^TEeFbkRdx*fxUi2HnR9M`Q{VX{@pIOB*vc!6fKU66^^AVs zb)y8S;f72KWPn%`bLkD9z$=1|)R+c&gxl&X|$E zcUU1(>Y{mLUjF(+;XXL3;sf7R7`&^Y4HSi+$6)m-JKx?|LkJvKear$AmCg6T>Yuub@JR3^!d4!Y z-txwM>|*k^(I+jk?H|m2Y0nOm_rrvhgM)fJC@A%=%2~t%0B{FY&sCXp?;yd-V&8u9#OU1Xv8vss2#d{~Ol*V^`f*`SdH#@_{eH!IfUMDEd!laqn$a zHELpNvwT5YKQAC=^!=xbNYR|d_F5wj7G9)wTvx9Om)`NMKJ#}J^ZsufgpKTqCU~zt zcwr0i(=dNRKL`2d4EIL%F1^ zU8dZh7J??FK9U@Y>zObBiXcb_&ow0D<4f{A+r7%EDU*_tyd^@pa4hbBf_q?Kd<_FX zRv$gTKzbmKb97+*BvAuADJuh&>!A-M%BpP1?hoAWa49GZHClblCgr2c${t4}0&Vp8XaN*SC~8&w2K^}!ZELsL5C#uLX=1|LMw$X35$?MCA1Ph% ziW?BCh(5A&7Yw3HR#3wY##~m0bascPG6drs3p60wZu~*`PLiVsjh1J8w5}dy{J(N( z`XEo|uA57hPij*okap!45 zRCwUwBM^a21;b$%r)v&5a(IgG)h_PNFIl(m^rg1@*7nvkoVmo`%zRw3vkB7|M`MP+ z&xEBIs_|#28d~R6Pe6Py5Rzk{A6pw5n_L)P&HpU^?3h3Pvpc4@$2dE_w8;sWr?x3d zc0*8iIJ_n%&SAMfM-@k4q0}=!6c1W8p=bf#5&2#9QkIt5HoW==S(O7qP$s!EaGses z0aEnA&&;@Z$;PUv=MfyMt4F^lK@0~Ml#5A(Kge=Y<>l@3vVEp69kTR57?|Ue%g(-e z6mmy4h_wFa6GU{>Y|pPqM&ynT@TE~GT$$i@mC(oVBx<_eNqMc;UPmPn&tDl@J-OM0`0!Lz zFd*o=Md+6>Ixg$@AL8W&H`%{v!Y9v3PuLR29xcM^vB#lWSd(R7sL_}X8@n$scM znQ8qypSn^mJ`XfBH#+zG1`C`KnAqDCYAD+avyHq<{{GmS_(Ak8@N*9YCc8n?{pQV_ zZ@wsxkT9}#6qg2jqO4l1+i+t~)*x_v^T{q{U9bbABP=K5(DAW`7zv+bq3*mTZu&iD z4c%Q<%f*rlq!UA~ z&MzW!pP~E00T?vO(4vosWs)Nm9D@CWB?SZmgUfQNB0ywhM+;x>-@TKI{-rFZ3MBzP zuJM2O(T^eGYkmEh(Jy~74-i?#&w0|8lTq@4pI2xZtpcjupB-6xmnH@qpGCz}@;qGb znEdaaZXcw_WMymHH9eg;33q*m9l%D%p8*08r}L-d&}BU5&aJgIi4R9u&wl9JknrON zZ}$ibKRr}fSBK!Q3Ug0w^dehF&*QD;AtI+f`-7!s@^9ZVTpOuwv@`vDNzxxr@cbY5 zmm2{8IQMHvpo%+FhBh=L^G&Yfjrmc>+wFOFc}`8iniQ#kvYCkAJu!mhbJA@>*(N-|E_0U{n-}PSnpK+z?C&1GGbQgtS7nW#b-)`sW_pLNxB^ zh7+)omxrnx72-Eo_zKN=8H|c7Yg_03Gxu!so_-^nJIj0#mSp5%kQeFQx!jx?J(24~ zL4sU^f&nW0!r}rV3R-Vh7wI1`;fKgm>keiW;K(d^;t8|X=*?|va1!ynP~4g;S$B4J zHi5{%@mqc1r3Zpe6r?zHgZ4KPJ-EdGZM-q@Ttr%Uo}PTH(u#^qY2{R{Bdc!eYSuY} z>QN0hkZ?ce5c6x~+~|=+M~4a$A6FnwpZ<%rZ`H}E>G55>m(8geBy?R`Y@gnV-V;G& zF`W;I<*(a!N}oeIWU(L)diXf(TVr;@23+*8po**QRVN^35%-*33P$qy#LvhE2%ZFi z2ngn*QdLyCr>DbfONby5L*1b{1hSTDCwU;`G0@e*bTS>SNF>CAKQGQP8^pzSt*+Q; zJnwZ{F=hX4l+j@D+R;&HhjI3tMN!W9V9)W@MaSeMjHjoL-6cLO#Qrp18Ad=TK?)#b zMANqyD}0khD_eV!1x-oIBSzh?AL6_Zmi-4IpU(UwEzRd_3)5_=Mxa{^TxpZ-rFVY@ z&1NbFf6zz$AT0bMYlOVgOO}eR5!PuQMv@3X9*$sK!Dg}FG;;98uIDM%|IKMOlvvOF zGTOJ+RQzm*?@z~lWk5;VC){7|}%Had|)O?R~SsUoN zfJsIKkPJs=^ue;OB)EwWvDVU$>`}3iygn3vNA}| zKvGultQAgtuW(@cXQnrJS}nIfOl|f7*Io|<{3PhTF+?Nn&AO_77%4Sh>8QW{hXKM|ftn@ft~>5%ds6LW5p?>94Gbbr1+{?MFUCEA>owE5K4 zD(r9eNY+jh%AvB{`(E1L%u%op&Pie*Q(`L2k;Zp2Il(pB)0YIgM{lD}g1@geUGFRm z&kdRAoLp$@Lcq~qy8}rLT>eS;=>d9QK_FiL#RnnG&i7Ae(Eg>?2o;3teaqj+sGh~C;DhNcRz{aZ8roBEtBG5cU?*tv&?)7%j%zfR_4+}?j5STG3N zJM5JHydYanCb=vZdq#7kZ4`Nlo97zR_;xKernJx_)`peZb%0T%Lh5fZmEpYBt*$X7 zdWKx=Sm{z@V?TM*`tzro12?EB9tWMAVY}XSxd1Y`IbQpl$O8YzHi+%k?2;Ru98I*X zIPve;FmA6A$hsV3wbB=7i(r#AQw3jS>8W;go|KFhgJ~tG!RRhM) z%)==Uz(lR*Fo8(Hn*6O6?`%~)&ECVo!~Fsc7UA~n-PAOnQywED>uN3BuSvTaR}udw z7lgaVR2Ajd!ZXN;uE#3TRM{(v=yfo2H(&1$cymSWz%J$BTd;51-+joOc6;Kir@xkGsm>;x| z1dVij)dR_NisL_j$k@Mz`B7-$pgAYtosvu0K)5Iobj=Fc<78!RziFd-lLU3t!b9Tt zbeBq`h=Awl-!OR&mx{Nk8+J}TSnyHu5R9AYi?NC~Kc_!8Ds9_O*Zhce6pqy4^BJY_ zGmEci%)!ks3|*bUR0E+CPsf7R#@L{_W`UFp>CQHljiu`f{)ELy%(cPdFV*a3m-{Uj zKOJ}Q0TM-j76*Eh2=^S_NhXh+#!8kCBfyy0ByteeF?lVife_@xPqGjW0F>QxRX`O@ z%Il|x@@wSPM#$LWtR;9rqUF==nA77$&Z+?tL>=Gv+l)G=vt8;O>OW;4y*arbfjHS! zMv0KKtjg3=^3XP(8KH1BhQFRClwvc=qw`f9useQaXugq~_wvn*%dd|)Rz4#BrK@3B z9(?iS9_#+1*TL`50(4Zc1Kqy}xQ`%Pj9QH{}d*^HlL5#&oz^B|BU=m=uBh3Se5))dXrhrvpakH0b>(9kr1!4m<@}) zfIL@!=~#?`x>;59s|RLmKk8IyK|2WyTB48`ZP!<+U~K;9W6+foygj5Rz3i%#zDIV0*4ChkligX5U++~zDS7|_oziMXUyxiICAtLLa^GCdI*wuJ{y z0%(94<~9?RV|7!OH@%R~lW_5JW5~h^t^>wdhYAyfLrgX23*^{yD2yeI9>5vfoBhTZ z6n!t|ce^2ZetGuN?vnkAuf# zj8+~lqIx~m_F9B_2wd(5i&BKpBT8FuKhGL=;r*)$pb!kwRi%20WsbBn9eXz#o>N1k zi5)9l+bl#Wif#so?|CftZ1oW_j>YY*#5_-E+81LBIwFiTsr~A(TNxZWa=fso>A>NI z60x(5Ao^=*nUzJi6o35MHO+Ss5ocYhvIrQIZ((y2@tuq3-ml@y{W4rU9%ajegX6>C z2m6Q9h6UNRSXz8Hz*J{i= zV=ElyUasu${v27Ogppq^qkIMHXHdVG4svNm!`rWB49wXenEElpZnv}SBRLd8&8a$I zE5p}y&4-8>l%BRi*w6l+K3?1|>=jAcMW2i{s27FO>FqJHby6gxJH!EQsxY}bIJ=4( z&VnZQYXG;blJy#BfX`+M`Gx+R9u-LRhDtl1m)qL=Aye2O%K0~-sKa}3zq%JiQ zTg6)5l?bhgA9hx25~vQ{&zbQt(*nOG6GXJa7rm|-H`zPUMwwy6!kKqN%jrI799JXK zyI*vb^X{B+vc^pa2Z4)v7QyC=0n%TZKm2pvbm$^YbS5o~JDT#8KESFVs4SDw7ZLF= zw5XZRkQLsJHJfj0l4SKmg(8f$zmY+@jg^}Z=B9o3B3IlsRIIvzL1i zjIulv2)Luotc)(PyRsGZAq*?I-|%`&zC(K4$@4uv*y;y-8i||J#H;xLvi%?gUXbdy zZyc{a7f*o7!%UKs_#oEOdmjf2 zEb$`R4?hkzAhsMYVdK>3ol`oG`uyf>$buWly;XTVLIO-ZwUvM8^#Z4>dN!jbUPbX$ zgZe*JmyYaPD(M^E7THSe70U!VkE@{siD#b#7CKbE)b8P?AGX8{6-Wt z-BGKsDumkF-5s)4TvAw<6S}8O<-7!5+rUMjBf92=vnmtqF z%{~2=D*C#=qQ+DXq+~uDj3Bqzm_Q&}wx;mA5*46 zz{y?1x}?gu#IZ9L!LS^;kH7KYb=A8yk#A%!4_Hc}1?*Jzd<6h*L@mxrj?7&~iS zNrZ&#A(<1jCs|n!4MX~>O!xIY;sy5@B#=@UT0%Lvsk7h0uN6&*cW`xMnAIfWgT~X- z##&%bGnTAiWoCdN`T~T%KV0 zp|Ymt^yJu=^`pbcPEY5Fg|R@{O3`vRPHgi%`TXR-r75XqPbfb|*+zIbI^G4CkbMJb z^u|mEA;Loto!OLe;nS%DCBKYh&tyMb%G_(*{*PE#gu5&0{oUK@yW3b|Z5$=%GsttT z&MZT%#gx>*7gRc-|MaX&oxc9GMna)u9fN`&+aF}87PPAPLfefish$#liBFuiwE-)u zAsnNi;tNoy8XpjU_{6!8N7+cT6wtAfUC6tuP*l`2#94s`-VD39q>mwtlbH`ZqCJRd zslzk^Rt7}B8?rj#>FG>qh3iiI5GWEg{{Kda`Wo3dsoXYb$LU*ayD%88E?|! ziMjSO_f$9p9)WXfSticSmK;LM?YKdhCJgN|ieF-X_Dn=aRpD;*4mtSQoOL?L_}p`46TRWMh|!LWRBTuxdPfzG4rgmd zj9%_9v#gp+8H9x7lvhxW_q~`tS4h9ae&R{=tIlP+Ty>>_Dzl zvKG<25mv^8rgFkZ5pr}T=Fv;NZuomxTP)KyYF#|QIxEIAJ3wN$TZU~Vr!_0Zt)zzg zs2`boyj+TQdkl+;uIZkQT|Xl$CL7)rH|W2b^F$n0XuWWD9B()GO5&Z4Auz#K7LCqx zoffh_eq$sM5cmQYA8t&blZJnH2T@fQS8Q8s&bd%`{M(y`Koht2EsWI}G{5>OYL{vr zUpR+*l0s~-n;R@WGjjGn{|)rE#wj3nZz}fuEAG;5Ums+k&dkE^x8ZAXoS|9%v}^3S zrDEZSS|)Bm8G%|3LKHCnAVX!@O(@d&LwZVNUdI9sxb!IgCPf7F1Qx2WI&{3si4sM? zyy7VXbxbV73ZoOw20>{C*iNv6g;hufBA4BjGALP2USdqt)hq24B%=HD=~ylrqI4P3 zjO?}-I^HS0C01n<{m!!y9ma1fU+7pvWf&D#OFRLgW$8B3`VXE|N0 z_En*oGMe(MzENj&GX6#ErhJSD{kil~2eWF*W`Q~ENGB&G#m3eY#6chz5@_V87duj# zSW5DA75DlF`it@BPf@t5`l|L@RDWc2~y2Y*kHfl=(eHnZZ zOK?%QvZ_v10d;&@Sq?!1Qgs+3!G56phPnO;Uc>Pu;)p_S*$bWb#IIv$c(_O{Wx`t~ z*fAAtJbSBuNJnNH=PLi&I8?`v!qHukT$;loG zlR9Z?*bPhs^e!+Fqu~)G8ZnYFQ6tn8EScNo4+?3Ja5z16cd0JBMmD&%mXZJnFfc-- zw1{>kWs*DyYq6YPcSRFS=p`}M|=1^9}l_H&ll{VokM2ZY7HcQKqtaWI}j%pN0U`x#^ROb zd{v36<4w58J|+omU`RS*BAaJ2t+~p8kxdNmQ;hwAoI8CG^uwIw6<+xSlz5jIU#FJ> z>t<{GdC%})Dm<69CXlBpHITufH z9OL4}=>T3@LCFw)FhYPl6dVb^)4>kRCWbbEgXm()Y>YoqGYoHWzY~XZ3@ykwkyuBy zYc(7W_1=$k#6s4LVB`~22bABQZoIRV)}O%^5!V??#Omjqk5OLtZKGvt^dYXFgo)MU zRz=1Bu2rpKag{FfjVV3U*yrK{BW9&L@u$NrW#3q5IiBiga1l-pcFv3=78(&%1(V}4!i*e*RskRwH<*m4 zv2pRq0QNLx8kw3kyDYJH9BCj`IMhpv+FWaG^%_^X0y1Uk8BIi@d)m)4b#?T%m5xB>S%`_+oU&;yRV2TUBH_|8z8!PD>8y^p!YCqh8;2WLA{%KDX1puNwPti?5O7mk73IdeKTyXLQ>ltC)?VY# z#3sdLM#9XQ(=_E~8oflCBFNi?)$%jRCcY0tx5|e8L*(Y(sL!SchW|Mft87ADh_3Qe z`jEcm_FUpl`(WX(JpKb4`{0CRu|QbIz(vFxE@b$@g9G7papcQg>Q~}32Ub2%Z5<-& z&_RbwoCbbDi$pslDSO6nAv6>omf6JIAdHncb@7jjyP+MQXz(rX-0UFHQ8ZWuV|5&o z8IB!0TnM}$bxC-coMYWw)ZOlBScNDqKfSNWC;Tvagc|hv#dY8SHBj?--WjtKZ*93W z2oEqgLfC{;!jZ5|V)UJF@RFd9s-9oz9B_s|`m} zR}f#z^mm7UA~x|AcLE+z+d$l_{YcY}7f%9S4Lu#cDK_?fc~Ied2g3VwKolBG#zPST znv?E$G?aL*rkyt4BD<9;eQ<0BA?iK+Q=y&~`z&7$CUBs;1e5zZL;(!nw7lXkjsf^+ z-6Iq5vaOQVc(Pq(9ij-{7uS$XA}!fh1oH#dF4zgL5ve+=u!>k&YdnViu@v&BKtd#t z4>Uaq?!4!Pd5w?`@6-QMLBk9>h`vo!$|jN&0SS|ts-M+o3@Qw71QC;3p~pgN9PM{P z=O!<-hR8zVo))qRB!{E$MI;hENve`6Zb)wmGCgg!lQ}1ZvcAl(&brN-jY_X*1{cpP z-v-<(>+dXnCKj*dH*PqCH)8taW7`ZJoma8jvh*$z*%5k7l zZD|uV8Xy|d3KH%5uu|SneQ{NUkx!wM(3vc(kTgDtHg-V$O7dC)9tbVe@f?OV-!dWY zG1etO!WW`WV#kwIF|z0`Ki5G+3xyjbZAId_`2{6*qY5_h1;-{%4a0slCh`jcQn=D*umb^L5Ax4h#8x} ztzObjz&&O+kPks$ipb6(voxC0s)}^e&>eB?Pxz&s6-DdAmFuUWV`^0e`H<|RrmGn- zDl+z8(|+!b@RKmmBaZXqD8b?4;D#9GWd}%*{ytH)T2f-iQ-vtRFDCE>vit87u^ex% z4oxu*aW$*(K|lT~PYj1|ez#W_V?ar&-fT+`t~L>@Vw7{O z0u>Ke6m#~giq4vXxR*~%CPMmyFCmG!4mtQZSrcW$4YRo9~^Pq7u6l(>Zr2p;H3MMDlSn&3u~hU^obT~DYJ?$dc4_|Xbw6wRjJ7`>Vt!e5x}eLBqHaG1m0S|e{#>NTc(Xo>cOp?7t#YE%t?^bk)u@q zR$&L`wY_nlk|3A4;ZT0RO(Hx48;1n4*;u3M@BZ0Y`6x9=2ei=i&%Zw-R%xI_L1t#f z_zzEoec+5(H8jrWFCq5z%=3X zW{QnH)UYd}(edqK^|vysIO_%dwbih?ajBF$@e-)(Xr|ML!bEEe_T|eWUdO52q4P#2c|in?v1?r-baDNVUsN#hk+VLm znNCPNb-pX}AC@ZSv~1+f{AS-+ta_dMzMS3C4DR?x*jRFoP%~7I6V~J6u7;{7t^3p||%$1V9Uv$DOiMN9bWJj6D zux}hN(9~287I7ddL{?0yc-A`q!HuilVROp5U_Lf~A>97b)mY^}QcaFCz&Ppi%(3-F zBi4q^v0x(hrJHQrM(6yNep3lgA;qP>b?I~!Oy{+Cawo5%NRvB!xO#-7qPlfCM(93p z`o{+7pB-RUQ#bf`f9$sjatr)*5XhO8%b$kjvt^ENF#k1CNbh7o?~hMH z-X|3g**Ea}&cWA`%IAj||Ig3m{8f7+BOjyccgqxGW_dx;q( zdi39UbT28zzs6N9&KtXD$X1m#p_|NCD;!`&rk_%?dVIcK8E-v7KWR~CV|!KBdM=etjPMxz zmCpAJLk2Ys{H3>$_hL98=-!->7Kpnz7RJ_5Ib?>##bvDB6m4+%Yv_aGjGpFdg9 zgMwfpS;8+_&^E#+V_8niQyX}BiC9xkeXY!bmSR>mC?{71LOhJx- zK-?}QpzHL%KM3s7AiihO`AJ1#+kVtz3+<4$9i77v6O&qwKpc8YoEs8Z$k6beA~m(Y zs)qN2xw%9{Bu1bKE=I@Cx49?%_y>&ZG4QmiWeE`fhcDC9YB9x^;e1W?XyXma>{txi zA-|_?NkkU4MdsCc24#RYEIxhJ$?)n~84=ihqg(L=twlQ76?yf~mpu6>2lpq;1&?O} zJx>|PzxZRtiO{#63IK#f9b)$s1U9Ze~%U2KZv;rcc!H>Y%5QAEg94PnMd*UZf9=&JC8goSWa zPCh9aIjYSrlji1p74rqpDJgeB;nq+3M4O3V(v0fr7|cS?3@51NDPJ*nsyiRbKklwU z1vTT*0-ai0SH}=}8=)v94o4(}BlQ4_jzsV1csz+MjR6F$DzsSiEte=`U;~6~N(8K+ z_wGp{{7Jk4*I+3IKi_-w4)w3*p1J&^xs$@6x9+@NXkwvHQ<9uf=+r88hRYQPT)o)P ze1+-foo`ZS?RKibUHPt-@%wqSh?Pn-r7tqMk9Jq#zqWH<+U`G|HZVl3I^PJHb)Q=+ zQaQt@s;c_;?f^9m-y@Pb6H?j# zB2w>HqCA^S6Zc5l2*XI)hC;W^z_u*z>WJXzMi5fxy83M3 z#uNVG^3bjtPLM#1p6fw!2$$#cB?o>;-S&v?z>OvRfyeE$8K1Wa4Q#5olPfEnIFDzl zWfe5Oaqv*Sc0*zW5d--MSKoX6U*?n|U>-Oo>z#%&-m24l;*2UPE1Pv09k)N2j;E~2 zO-P=iuo+xId%Q(xxoL&>1z|lWoCm= z7G;Zyin!ZDO#;aazGD_&;jVz3+`nmPjx4qA&cn@1^w+Z z=?=ve%jg(ts?mf%MvaaJy|Y=A%gX3n>~HA|40ABb3_rm`C0#I-^Ar6}_vU=c^hF{z zOYIi+CXU({=9h1}XiP#ultt{Fj+$8W3L`roFPUoqBSv?R$GciUNQv zuoXsUbv{K1`iAyV`k%_&4|Dc=h6Xi5qiFATEPvk`-lLS_xK|-=RKE#(0~Sb$^PMKZUbX1Jtc>ge8OhP+KVe@reEd zF2>2?U!mGvl`USaDbt3=;W}ZtZWBYhR0Q*FG$k#9LUMYKH&QsiudmNCOOv&4l;~{- zUrZ4#9U|vQCW;Axm7B^<_5ROW3Q6Hp%r!WYFZ;GM2~!dndHqnW`NFwCOS%m1kj-ft zJDO0e$%P7AKH^u%6)nGtW?GZ1u+VMhRdb0u4+8&jxo?LvXJmz5#YU9@9`&+Glo{&Qhge_G6tX9VI$_g+$qy<&XK6#d!-16%3 z5<(w8zN~Xe3^HWRN{NvN13=JXQxrvv+Vt=xa@5L|*7# zxs6*t+VaJp{}LS_ooGwvN~mYItMDp6%2-yvG+1UeKa=oWyi$G`s@@ZLpU$ZCi54jK zpf58?$P2I#c^eect!5#H+O#UiPoOQ=PSqAD=m@@LYWih8J;1^H{%h>%DzrkR6lCbp zEp-7NRX_)1foXh0{0d=IgYw&ZAzL|O*{j5$NoDow7}W6$VMOBhON<%1JHIwCCaaS( zmeD(x1pd|0sA!k1pVcodW;g;}ck`*nCV#242$HLb zt)5b?v{)hCE&~pv1=v<}j7&5%BtNv5ZX2l;F5o|!KgvHTi=f_0&U@k!Id$ENZrikJ zBkn-878{H~;rkpqp{(rccB7EBKdS~E(W4V<;NN;O{1dPt2l`4Vp*#HAqOeOF;{Jq+ z28MEYRcJzwMXxC~rka~N@P+H3NI=NEcScfY#!;w)s~WN%TBdw7UE#I5)w92bVvMp} z24}gE^NB%_<>kM6)2@xV*pq4-h-1@+s>aPc83_QR1jM1&l0YKSQaz7e4vM+>hE?%f zk?M6m6ExWy(DBl=p4YLR<_Nr84RQ(aJKGvh?jfvA#&ElM!^am21PJEL?9F2b%X;hu z{-Y(M^_x_5hm}pVP7}Pd9}61Z2d6s zNkkQoH_33)IbbJTdkk z()oSBoGQnMh`k@R)+2vzH-I1X3HH<=7H&LWW1jVB%RDuk@V~R^*o4$>oa0Z9Dv~g) zrn_0K>BOI1pPcT_NBiVk9i$An-npzpQ@SKTv3`8`&=M(KHs*ut1?%q|4*X!@MSRdF zCr7L6;y>!-@3xz{Lb0{q+21XMZVYTxKDsMap&es){@Le#bXG$^%h1H;)A-SsfL1IJ znK0QJPMY_E+E~k)44@y&OGh<-M~C39LTg?h1lsh=iQec>*M!@yN8=Vn(VV>DR2+#7BFR zR6())#FoZGJQ}4RWP0nB#x4gPP#l|K7Y_7Y_roLX#^dXmT`EqhaMgo{pf#Ea)T4cV zV`kT}E|x5L|LR$%o)810ELmWDdb5nFXsJPt1Wjhw7F^)#k(HP4a3dOAKWk~}In>?w zS$M-FdATAl1e`}|(*qZY$bvVBF_<#xjV^!M1;pL0u}j8ZW3jTbf&~BLM2OyIoiQr% z!Qz^!)j!GD@?o0o_d1X4EHWe1qx(y%0LmZsXjME`XcyW^?e~|Z zpC=C}pJ2(Tt3ygphE`XbJ!)^KXYz}Gxonr+)SYIWIPc7b1#CL(GR@jg2s%S(#Ki3n zy=pX}m=kkfX5@z~*tG_an(B}4GS-Ch%@9*OmmlRoZ$G2(Uq#F})iYlWyVcpB{)kdm zKp#HKlL{uV!xsyLzrC}9GG5I1nhz{UdR!$U^erfSPyhvqsuw3Bc*mP}H7CcFHCK*p zazmW@Z;VqM5xm^&u#AzoaOW5~9!7QoEO0yU8Th-ryJ+ILz0mY~G^8XYk;cczlO`J| z>hKXk*!FI>k(sm3iip1D+_9k0A;opqlfG$_XWsRK#1DnnA4mY?P%)CebekdECoV@-xC++=0dZU_nwB^o zxK>R!XlSX>_%N_U0RW!?WR9j3KF>TtO8haG+xAow%}lk$Mu&7WsoqsTE3L-y6CbH9 zh#QfINM}#QhK#(t54dpr(?ob=a##25APYG!oo|CxQ>YtZf}|kuyg3$*pJIMohC`bZ zq{nWEyrhJJbHbCN0v5D|6N@C-RxR{ED&cW+x;pbr0+%I?NZX_0bAa6tPH#}jVA_bX zaOW8Afh|yL*fMhLoIV0#DtW8;h{i{`JP0~?*vfAAS}CFClRW9c%bmRCzn zh4wZnQB(-Juj#V26$o3xbV`J1K7QGB>$tpvRyIQST?P%0D{DerFK)!xqQxnyl9dt! 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 z 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 V-$+(mInIElqNt&echfZBe*wGUR|Nn7 literal 0 HcmV?d00001 diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should allow to drag sticky note #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should allow to drag sticky note #0.png new file mode 100644 index 0000000000000000000000000000000000000000..77404342dfd951194295febe09a00fdf7e8de25b GIT binary patch literal 69916 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 7GMGL3#YL4T; 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 P0UXK->heW*&HVls^qopq literal 0 HcmV?d00001 diff --git a/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should disable sticky note when scenario is not saved #0.png b/designer/client/cypress/e2e/__image_snapshots__/electron/Linux/Sticky notes should disable sticky note when scenario is not saved #0.png new file mode 100644 index 0000000000000000000000000000000000000000..e3022e7c644e3404b5e5668ce228b101fd764dd3 GIT binary patch literal 71149 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 literal 0 HcmV?d00001 diff --git a/designer/client/cypress/e2e/components.cy.ts b/designer/client/cypress/e2e/components.cy.ts index 88587a19555..cf77c52d321 100644 --- a/designer/client/cypress/e2e/components.cy.ts +++ b/designer/client/cypress/e2e/components.cy.ts @@ -233,7 +233,7 @@ describe("Components list", () => { cy.matchQuery("?TEXT=xxx"); cy.viewport(1600, 500); cy.wait(500); //ensure "loading" mask is hidden - cy.get("#app-container>main").matchImage(); + cy.get("#app-container>main").matchImage({ maxDiffThreshold: 0.01 }); }); it("should allow filtering by processing mode", () => { diff --git a/designer/client/cypress/e2e/creatorToolbar.cy.ts b/designer/client/cypress/e2e/creatorToolbar.cy.ts index 0eb4e53d80e..304f048f375 100644 --- a/designer/client/cypress/e2e/creatorToolbar.cy.ts +++ b/designer/client/cypress/e2e/creatorToolbar.cy.ts @@ -26,6 +26,7 @@ describe("Creator toolbar", () => { cy.contains(/^types$/i).click(); cy.contains(/^services$/i).click(); cy.contains(/^sinks$/i).click(); + cy.contains(/^sticky notes$/i).click(); cy.reload(); cy.get("@toolbar").matchImage(); cy.get("@toolbar").find("input").type("var"); diff --git a/designer/client/cypress/e2e/stickyNotes.cy.ts b/designer/client/cypress/e2e/stickyNotes.cy.ts new file mode 100644 index 00000000000..5a8faef69b0 --- /dev/null +++ b/designer/client/cypress/e2e/stickyNotes.cy.ts @@ -0,0 +1,65 @@ +describe("Sticky notes", () => { + const seed = "stickyNotes"; + + before(() => { + cy.deleteAllTestProcesses({ filter: seed, force: true }); + }); + + beforeEach(() => { + cy.visitNewProcess(seed, "stickyNotes"); + }); + + const screenshotOptions: Cypress.MatchImageOptions = { + screenshotConfig: { clip: { x: 0, y: 0, width: 1400, height: 600 } }, + }; + + it("should allow to drag sticky note", () => { + cy.layoutScenario(); + cy.contains(/^sticky notes$/i) + .should("exist") + .scrollIntoView(); + cy.get("[data-testid='component:sticky note']") + .should("be.visible") + .drag("#nk-graph-main", { + target: { + x: 600, + y: 300, + }, + force: true, + }); + + cy.get("[data-testid=graphPage]").matchImage(screenshotOptions); + }); + + it("should add text to note and display it as markdown", () => { + cy.layoutScenario(); + cy.contains(/^sticky notes$/i) + .should("exist") + .scrollIntoView(); + cy.get("[data-testid='component:sticky note']") + .should("be.visible") + .drag("#nk-graph-main", { + target: { + x: 600, + y: 300, + }, + force: true, + }); + cy.get(".sticky-note-content").dblclick(); + cy.get(".sticky-note-content textarea").type("# Title\n- p1\n- p2\n\n[link](href)"); + cy.get("[model-id='request']").click(); + cy.get("[data-testid=graphPage]").matchImage(screenshotOptions); + }); + + it("should disable sticky note when scenario is not saved", () => { + cy.layoutScenario(); + cy.contains(/^sticky notes$/i) + .should("exist") + .scrollIntoView(); + + cy.dragNode("request", { x: 600, y: 300 }); + + cy.get("[data-testid='component:sticky note']").should("have.class", "tool disabled"); + cy.get("[data-testid=graphPage]").matchImage(screenshotOptions); + }); +}); diff --git a/designer/client/cypress/fixtures/stickyNotes.json b/designer/client/cypress/fixtures/stickyNotes.json new file mode 100644 index 00000000000..d31aa75ff85 --- /dev/null +++ b/designer/client/cypress/fixtures/stickyNotes.json @@ -0,0 +1,58 @@ +{ + "metaData": { + "id": "sticky", + "additionalFields": { + "description": null, + "properties": { + "inputSchema": "{}", + "outputSchema": "{}", + "slug": "sticky" + }, + "metaDataType": "RequestResponseMetaData", + "showDescription": false + } + }, + "nodes": [ + { + "id": "request", + "ref": { + "typ": "request", + "parameters": [] + }, + "additionalFields": { + "description": null, + "layoutData": { + "x": 0, + "y": 0 + } + }, + "type": "Source" + }, + { + "id": "response", + "ref": { + "typ": "response", + "parameters": [ + { + "name": "Raw editor", + "expression": { + "language": "spel", + "expression": "false" + } + } + ] + }, + "endResult": null, + "isDisabled": null, + "additionalFields": { + "description": null, + "layoutData": { + "x": 0, + "y": 180 + } + }, + "type": "Sink" + } + ], + "additionalBranches": [] +} diff --git a/designer/client/src/actions/actionTypes.ts b/designer/client/src/actions/actionTypes.ts index 306aaea65fc..31218cd0e4b 100644 --- a/designer/client/src/actions/actionTypes.ts +++ b/designer/client/src/actions/actionTypes.ts @@ -10,6 +10,8 @@ export type ActionTypes = | "DELETE_NODES" | "NODES_CONNECTED" | "NODES_DISCONNECTED" + | "STICKY_NOTES_UPDATED" + | "STICKY_NOTE_DELETED" | "VALIDATION_RESULT" | "COPY_SELECTION" | "CUT_SELECTION" diff --git a/designer/client/src/actions/nk/assignSettings.ts b/designer/client/src/actions/nk/assignSettings.ts index fb54a22f52b..f26d0dab223 100644 --- a/designer/client/src/actions/nk/assignSettings.ts +++ b/designer/client/src/actions/nk/assignSettings.ts @@ -39,6 +39,13 @@ export type FeaturesSettings = { redirectAfterArchive: boolean; usageStatisticsReports: UsageStatisticsReports; surveySettings: SurveySettings; + stickyNotesSettings: StickyNotesSettings; +}; + +export type StickyNotesSettings = { + maxContentLength: number; + maxNotesCount: number; + enabled: boolean; }; export type TestDataSettings = { diff --git a/designer/client/src/actions/nk/process.ts b/designer/client/src/actions/nk/process.ts index d7209ee2de0..b00dfffe989 100644 --- a/designer/client/src/actions/nk/process.ts +++ b/designer/client/src/actions/nk/process.ts @@ -6,6 +6,9 @@ import { getProcessDefinitionData } from "../../reducers/selectors/settings"; import { ProcessDefinitionData, ScenarioGraph } from "../../types"; import { ThunkAction } from "../reduxTypes"; import HttpService from "./../../http/HttpService"; +import { layoutChanged, Position } from "./ui/layout"; +import { flushSync } from "react-dom"; +import { Dimensions, StickyNote } from "../../common/StickyNote"; export type ScenarioActions = | { type: "CORRECT_INVALID_SCENARIO"; processDefinitionData: ProcessDefinitionData } @@ -17,6 +20,7 @@ export function fetchProcessToDisplay(processName: ProcessName, versionId?: Proc return HttpService.fetchProcessDetails(processName, versionId).then((response) => { dispatch(displayTestCapabilities(processName, response.data.scenarioGraph)); + dispatch(fetchStickyNotesForScenario(processName, response.data.processVersionId)); dispatch({ type: "DISPLAY_PROCESS", scenario: response.data, @@ -56,6 +60,45 @@ export function displayTestCapabilities(processName: ProcessName, scenarioGraph: ); } +const refreshStickyNotes = (dispatch, scenarioName: string, scenarioVersionId: number) => { + return HttpService.getStickyNotes(scenarioName, scenarioVersionId).then((stickyNotes) => { + flushSync(() => { + dispatch({ type: "STICKY_NOTES_UPDATED", stickyNotes: stickyNotes.data }); + dispatch(layoutChanged()); + }); + }); +}; + +export function fetchStickyNotesForScenario(scenarioName: string, scenarioVersionId: number): ThunkAction { + return (dispatch) => refreshStickyNotes(dispatch, scenarioName, scenarioVersionId); +} + +export function stickyNoteUpdated(scenarioName: string, scenarioVersionId: number, stickyNote: StickyNote): ThunkAction { + return (dispatch) => { + HttpService.updateStickyNote(scenarioName, scenarioVersionId, stickyNote).then((_) => { + refreshStickyNotes(dispatch, scenarioName, scenarioVersionId); + }); + }; +} + +export function stickyNoteDeleted(scenarioName: string, stickyNoteId: number): ThunkAction { + return (dispatch) => { + HttpService.deleteStickyNote(scenarioName, stickyNoteId).then(() => { + flushSync(() => { + dispatch({ type: "STICKY_NOTE_DELETED", stickyNoteId }); + }); + }); + }; +} + +export function stickyNoteAdded(scenarioName: string, scenarioVersionId: number, position: Position, dimensions: Dimensions): ThunkAction { + return (dispatch) => { + HttpService.addStickyNote(scenarioName, scenarioVersionId, position, dimensions).then((_) => { + refreshStickyNotes(dispatch, scenarioName, scenarioVersionId); + }); + }; +} + export function displayCurrentProcessVersion(processName: ProcessName) { return fetchProcessToDisplay(processName); } diff --git a/designer/client/src/actions/notificationActions.tsx b/designer/client/src/actions/notificationActions.tsx index bd3250bfef9..44a2beff88b 100644 --- a/designer/client/src/actions/notificationActions.tsx +++ b/designer/client/src/actions/notificationActions.tsx @@ -2,6 +2,7 @@ import React from "react"; import Notifications from "react-notification-system-redux"; import CheckCircleOutlinedIcon from "@mui/icons-material/CheckCircleOutlined"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import WarningAmberOutlinedIcon from "@mui/icons-material/WarningAmberOutlined"; import Notification from "../components/notifications/Notification"; import { Action } from "./reduxTypes"; @@ -25,3 +26,10 @@ export function info(message: string): Action { children: } message={message} />, }); } + +export function warn(message: string): Action { + return Notifications.warning({ + autoDismiss: 10, + children: } message={message} />, + }); +} diff --git a/designer/client/src/assets/json/nodeAttributes.json b/designer/client/src/assets/json/nodeAttributes.json index 2e18a54a2ff..46910e6201b 100644 --- a/designer/client/src/assets/json/nodeAttributes.json +++ b/designer/client/src/assets/json/nodeAttributes.json @@ -38,6 +38,9 @@ "Aggregate": { "name": "Aggregate" }, + "StickyNote": { + "name": "StickyNote" + }, "CustomNode": { "name": "CustomNode" }, diff --git a/designer/client/src/common/StickyNote.ts b/designer/client/src/common/StickyNote.ts new file mode 100644 index 00000000000..d525b8fd992 --- /dev/null +++ b/designer/client/src/common/StickyNote.ts @@ -0,0 +1,16 @@ +import { LayoutData } from "../types"; + +export type Dimensions = { width: number; height: number }; +export type ColorValueHex = `#${string}`; + +export interface StickyNote { + id?: string; + noteId: number; + content: string; + layoutData: LayoutData; + dimensions: Dimensions; + color: ColorValueHex; + targetEdge?: string; + editedBy: string; + editedAt: string; +} diff --git a/designer/client/src/components/ComponentDragPreview.tsx b/designer/client/src/components/ComponentDragPreview.tsx index 1028f91776b..c927d701401 100644 --- a/designer/client/src/components/ComponentDragPreview.tsx +++ b/designer/client/src/components/ComponentDragPreview.tsx @@ -1,11 +1,13 @@ import { css } from "@emotion/css"; -import React, { forwardRef, useEffect, useMemo, useState } from "react"; +import React, { forwardRef, ReactPortal, useEffect, useMemo, useState } from "react"; import { useDragDropManager, useDragLayer } from "react-dnd"; import { createPortal } from "react-dom"; import { useDebouncedValue } from "rooks"; import { NodeType } from "../types"; import { ComponentPreview } from "./ComponentPreview"; import { DndTypes } from "./toolbars/creator/Tool"; +import { StickyNotePreview } from "./StickyNotePreview"; +import { StickyNoteType } from "../types/stickyNote"; function useNotNull(value: T) { const [current, setCurrent] = useState(() => value); @@ -53,17 +55,22 @@ export const ComponentDragPreview = forwardRef nu return null; } - return createPortal( -
-
- -
-
, - document.body, - ); + function createPortalForPreview(child: JSX.Element): ReactPortal { + return createPortal( +
+
+ {child} +
+
, + document.body, + ); + } + + if (node?.type === StickyNoteType) return createPortalForPreview(); + return createPortalForPreview(); }); diff --git a/designer/client/src/components/ComponentPreview.tsx b/designer/client/src/components/ComponentPreview.tsx index dacb8dd7dae..8053083663a 100644 --- a/designer/client/src/components/ComponentPreview.tsx +++ b/designer/client/src/components/ComponentPreview.tsx @@ -74,6 +74,7 @@ export function ComponentPreview({ node, isActive, isOver }: { node: NodeType; i })); const colors = isOver ? nodeColorsHover : nodeColors; + return (
diff --git a/designer/client/src/components/StickyNotePreview.tsx b/designer/client/src/components/StickyNotePreview.tsx new file mode 100644 index 00000000000..af37f0fd504 --- /dev/null +++ b/designer/client/src/components/StickyNotePreview.tsx @@ -0,0 +1,62 @@ +import { css, cx } from "@emotion/css"; +import React from "react"; +import { BORDER_RADIUS, CONTENT_PADDING, iconBackgroundSize, iconSize } from "./graph/EspNode/esp"; +import { PreloadedIcon, stickyNoteIconSrc } from "./toolbars/creator/ComponentIcon"; +import { alpha, useTheme } from "@mui/material"; +import { getBorderColor, getStickyNoteBackgroundColor } from "../containers/theme/helpers"; +import { STICKY_NOTE_CONSTRAINTS, STICKY_NOTE_DEFAULT_COLOR } from "./graph/EspNode/stickyNote"; + +const PREVIEW_SCALE = 0.9; +const ACTIVE_ROTATION = 2; +const INACTIVE_SCALE = 1.5; + +export function StickyNotePreview({ isActive, isOver }: { isActive?: boolean; isOver?: boolean }): JSX.Element { + const theme = useTheme(); + const scale = isOver ? 1 : PREVIEW_SCALE; + const rotation = isActive ? (isOver ? -ACTIVE_ROTATION : ACTIVE_ROTATION) : 0; + const finalScale = isActive ? 1 : INACTIVE_SCALE; + + const nodeStyles = css({ + position: "relative", + width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH, + height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT, + borderRadius: BORDER_RADIUS, + boxSizing: "content-box", + display: "inline-flex", + filter: `drop-shadow(0 4px 8px ${alpha(theme.palette.common.black, 0.5)})`, + borderWidth: 0.5, + borderStyle: "solid", + transformOrigin: "80% 50%", + transform: `translate(-80%, -50%) scale(${scale}) rotate(${rotation}deg) scale(${finalScale})`, + opacity: isActive ? undefined : 0, + transition: "all .5s, opacity .3s", + willChange: "transform, opacity, border-color, background-color", + }); + + const colors = css({ + opacity: 0.5, + borderColor: getBorderColor(theme), + backgroundColor: getStickyNoteBackgroundColor(theme, STICKY_NOTE_DEFAULT_COLOR).main, + }); + + const imageStyles = css({ + padding: iconSize / 2 - CONTENT_PADDING / 2, + margin: CONTENT_PADDING / 2, + borderRadius: BORDER_RADIUS, + width: iconBackgroundSize / 2, + height: iconBackgroundSize / 2, + color: theme.palette.common.black, + "> svg": { + height: iconSize, + width: iconSize, + }, + }); + + return ( +
+
+ +
+
+ ); +} diff --git a/designer/client/src/components/graph/EspNode/stickyNote.ts b/designer/client/src/components/graph/EspNode/stickyNote.ts new file mode 100644 index 00000000000..62d59811c61 --- /dev/null +++ b/designer/client/src/components/graph/EspNode/stickyNote.ts @@ -0,0 +1,128 @@ +import { Theme } from "@mui/material"; +import { dia, shapes, util, V } from "jointjs"; +import { getBorderColor } from "../../../containers/theme/helpers"; +import { StickyNote } from "../../../common/StickyNote"; +import { marked } from "marked"; +import { StickyNoteElement } from "../StickyNoteElement"; +import MarkupNodeJSON = dia.MarkupNodeJSON; + +export const STICKY_NOTE_CONSTRAINTS = { + MIN_WIDTH: 100, + MAX_WIDTH: 3000, + DEFAULT_WIDTH: 300, + MIN_HEIGHT: 100, + MAX_HEIGHT: 3000, + DEFAULT_HEIGHT: 250, +} as const; + +export const BORDER_RADIUS = 3; +export const CONTENT_PADDING = 5; +export const ICON_SIZE = 20; +export const STICKY_NOTE_DEFAULT_COLOR = "#eae672"; +export const MARKDOWN_EDITOR_NAME = "markdown-editor"; + +const border: dia.MarkupNodeJSON = { + selector: "border", + tagName: "path", + className: "body", + attributes: { + width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH, + height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT, + strokeWidth: 1, + fill: "none", + rx: BORDER_RADIUS, + }, +}; + +const icon: dia.MarkupNodeJSON = { + selector: "icon", + tagName: "use", + attributes: { + opacity: 1, + width: ICON_SIZE, + height: ICON_SIZE, + x: ICON_SIZE / 2, + y: ICON_SIZE / 2, + }, +}; + +const body: dia.MarkupNodeJSON = { + selector: "body", + tagName: "path", +}; + +const renderer = new marked.Renderer(); +renderer.link = function (href, title, text) { + return `
${text}`; +}; +renderer.image = function (href, title, text) { + // SVG don't support HTML img inside foreignObject + return `${text} (attached img)`; +}; + +const foreignObject = (stickyNote: StickyNote): MarkupNodeJSON => { + let parsed; + try { + parsed = marked.parse(stickyNote.content, { renderer }); + } catch (error) { + console.error("Failed to parse markdown:", error); + parsed = "Error: Could not parse content. See error logs in console"; + } + const singleMarkupNode = util.svg/* xml */ ` + +
+ +
${parsed}
+
+
+ `[0]; + return singleMarkupNode as MarkupNodeJSON; +}; + +export const stickyNotePath = "M 0 0 L 19 0 L 19 19 L 0 19 L 0 0"; + +const defaults = (theme: Theme) => + util.defaultsDeep( + { + size: { + width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH, + height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT, + }, + attrs: { + body: { + refD: stickyNotePath, + strokeWidth: 2, + fill: "#eae672", + filter: { + name: "dropShadow", + args: { + dx: 1, + dy: 1, + blur: 5, + opacity: 0.4, + }, + }, + }, + foreignObject: { + width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH, + height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT - ICON_SIZE - CONTENT_PADDING * 4, + y: CONTENT_PADDING * 4 + ICON_SIZE, + fill: getBorderColor(theme), + }, + border: { + refD: stickyNotePath, + stroke: getBorderColor(theme), + }, + }, + }, + shapes.devs.Model.prototype.defaults, + ); + +const protoProps = (theme: Theme, stickyNote: StickyNote) => { + return { + markup: [body, border, foreignObject(stickyNote), icon], + }; +}; + +export const StickyNoteShape = (theme: Theme, stickyNote: StickyNote) => + StickyNoteElement(defaults(theme), protoProps(theme, stickyNote)) as typeof shapes.devs.Model; diff --git a/designer/client/src/components/graph/EspNode/stickyNoteElements.ts b/designer/client/src/components/graph/EspNode/stickyNoteElements.ts new file mode 100644 index 00000000000..13649530032 --- /dev/null +++ b/designer/client/src/components/graph/EspNode/stickyNoteElements.ts @@ -0,0 +1,137 @@ +import { ProcessDefinitionData } from "../../../types"; +import { Theme } from "@mui/material"; +import { StickyNote } from "../../../common/StickyNote"; +import { dia, elementTools, shapes } from "jointjs"; +import { stickyNoteIcon } from "../../toolbars/creator/ComponentIcon"; +import { createStickyNoteId } from "../../../types/stickyNote"; +import { getStickyNoteBackgroundColor } from "../../../containers/theme/helpers"; +import { CONTENT_PADDING, ICON_SIZE, MARKDOWN_EDITOR_NAME, STICKY_NOTE_CONSTRAINTS, StickyNoteShape } from "./stickyNote"; +import { Events } from "../types"; + +export type ModelWithTool = { + model: shapes.devs.Model; + tools: dia.ToolsView; +}; + +export function makeStickyNoteElement( + processDefinitionData: ProcessDefinitionData, + theme: Theme, +): (stickyNote: StickyNote) => ModelWithTool { + return (stickyNote: StickyNote) => { + const attributes: shapes.devs.ModelAttributes = { + id: createStickyNoteId(stickyNote.noteId), + noteId: stickyNote.noteId, + attrs: { + size: { + width: stickyNote.dimensions.width, + height: stickyNote.dimensions.height, + }, + body: { + fill: getStickyNoteBackgroundColor(theme, stickyNote.color).main, + opacity: 1, + }, + foreignObject: { + width: stickyNote.dimensions.width, + height: stickyNote.dimensions.height - ICON_SIZE - CONTENT_PADDING * 4, + color: theme.palette.getContrastText(getStickyNoteBackgroundColor(theme, stickyNote.color).main), + }, + icon: { + xlinkHref: stickyNoteIcon(), + opacity: 1, + color: theme.palette.getContrastText(getStickyNoteBackgroundColor(theme, stickyNote.color).main), + }, + border: { + stroke: getStickyNoteBackgroundColor(theme, stickyNote.color).dark, + strokeWidth: 1, + }, + }, + rankDir: "R", + }; + + const ThemedStickyNoteShape = StickyNoteShape(theme, stickyNote); + const stickyNoteModel = new ThemedStickyNoteShape(attributes); + + const removeButtonTool = new elementTools.Remove({ + focusOpacity: 0.5, + rotate: true, + x: stickyNote.dimensions.width - 20, + y: "0%", + offset: { x: 0, y: 20 }, + className: "sticky-note-remove-tool", + action: function () { + stickyNoteModel.trigger(Events.CELL_DELETED, stickyNoteModel); + }, + }); + + const ResizeTool = elementTools.Control.extend({ + children: [ + { + tagName: "path", + selector: "handle", + attributes: { + d: "M 4 0 L 4 4 L 0 4 L 0 5 L 5 5 L 5 0 L 4 0", + stroke: getStickyNoteBackgroundColor(theme, stickyNote.color).light, + cursor: "se-resize", + }, + }, + { + tagName: "rect", + selector: "extras", + attributes: { + "pointer-events": "none", + fill: "none", + stroke: getStickyNoteBackgroundColor(theme, stickyNote.color).light, + "stroke-dasharray": "2,3", + rx: 6, + ry: 6, + }, + }, + ], + documentEvents: { + mousemove: "onPointerMove", + touchmove: "onPointerMove", + mouseup: "onPointerUpCustom", + touchend: "onPointerUpCustom", + touchcancel: "onPointerUp", + }, + getPosition: function (view) { + const model = view.model; + const { width, height } = model.size(); + return { x: width, y: height }; + }, + setPosition: function (view, coordinates) { + const model = view.model; + model.resize( + Math.max( + Math.min(STICKY_NOTE_CONSTRAINTS.MAX_WIDTH, Math.round(coordinates.x - 10)), + STICKY_NOTE_CONSTRAINTS.MIN_WIDTH, + ), + Math.max( + Math.min(STICKY_NOTE_CONSTRAINTS.MAX_HEIGHT, Math.round(coordinates.y - 10)), + STICKY_NOTE_CONSTRAINTS.MIN_HEIGHT, + ), + ); + }, + onPointerUpCustom: function (evt: dia.Event) { + this.onPointerUp(evt); + stickyNoteModel.trigger(Events.CELL_RESIZED, stickyNoteModel); + }, + }); + + const tools: dia.ToolsView = new dia.ToolsView({ + tools: [ + new ResizeTool({ + selector: "body", + scale: 2, + }), + removeButtonTool, + ], + }); + stickyNoteModel.resize( + Math.max(stickyNote.dimensions.width, STICKY_NOTE_CONSTRAINTS.MIN_WIDTH), + Math.max(stickyNote.dimensions.height, STICKY_NOTE_CONSTRAINTS.MIN_HEIGHT), + ); + stickyNoteModel.attr(`${MARKDOWN_EDITOR_NAME}/props/value`, stickyNote.content); + return { model: stickyNoteModel, tools }; + }; +} diff --git a/designer/client/src/components/graph/Graph.tsx b/designer/client/src/components/graph/Graph.tsx index 5f861458287..f1f045dc0b7 100644 --- a/designer/client/src/components/graph/Graph.tsx +++ b/designer/client/src/components/graph/Graph.tsx @@ -18,7 +18,14 @@ import { Scenario } from "../Process/types"; import { createUniqueArrowMarker } from "./arrowMarker"; import { updateNodeCounts } from "./EspNode/element"; import { getDefaultLinkCreator } from "./EspNode/link"; -import { applyCellChanges, calcLayout, createPaper, isModelElement } from "./GraphPartialsInTS"; +import { + applyCellChanges, + calcLayout, + createPaper, + getStickyNoteCopyFromCell, + isModelElement, + isStickyNoteElement, +} from "./GraphPartialsInTS"; import { getCellsToLayout } from "./GraphPartialsInTS/calcLayout"; import { isEdgeConnected } from "./GraphPartialsInTS/EdgeUtils"; import { updateLayout } from "./GraphPartialsInTS/updateLayout"; @@ -39,6 +46,11 @@ import { Events, GraphProps } from "./types"; import { filterDragHovered, getLinkNodes, setLinksHovered } from "./utils/dragHelpers"; import * as GraphUtils from "./utils/graphUtils"; import { handleGraphEvent } from "./utils/graphUtils"; +import { StickyNote } from "../../common/StickyNote"; +import { StickyNoteElement, StickyNoteElementView } from "./StickyNoteElement"; +import { STICKY_NOTE_CONSTRAINTS } from "./EspNode/stickyNote"; +import { NotificationActions } from "../../http/HttpService"; +import i18next from "i18next"; function clamp(number: number, max: number) { return Math.round(Math.min(max, Math.max(-max, number))); @@ -55,6 +67,15 @@ type Props = GraphProps & { theme: Theme; translation: UseTranslationResponse; handleStatisticsEvent: (event: TrackEventParams) => void; + notifications: NotificationActions; +}; + +export const nuGraphNamespace = { + ...shapes, + stickyNote: { + StickyNoteElement, + StickyNoteElementView, + }, }; function handleActionOnLongPress( @@ -111,6 +132,7 @@ export class Graph extends React.Component { model: this.graph, el: this.getEspGraphRef(), validateConnection: this.twoWayValidateConnection, + cellViewNamespace: nuGraphNamespace, validateMagnet: this.validateMagnet, interactive: (cellView: dia.CellView) => { const { model } = cellView; @@ -212,6 +234,24 @@ export class Graph extends React.Component { this.handleInjectBetweenNodes(cell.model, linkBelowCell); batchGroupBy.end(group); } + if (isStickyNoteElement(cell.model)) { + this.processGraphPaper.hideTools(); + if (!this.props.isPristine) { + this.props.notifications.warn( + i18next.t( + "notification.warn.cannotMoveOnUnsavedVersion", + "Save scenario before making any changes to sticky notes", + ), + ); + return; + } + cell.showTools(); + const updatedStickyNote = getStickyNoteCopyFromCell(this.props.stickyNotes, cell.model); + if (!updatedStickyNote) return; + const position = cell.model.get("position"); + updatedStickyNote.layoutData = { x: position.x, y: position.y }; + this.updateStickyNote(this.props.scenario.name, this.props.scenario.processVersionId, updatedStickyNote); + } }) .on(Events.LINK_CONNECT, (linkView: dia.LinkView, evt: dia.Event, targetView: dia.CellView, targetMagnet: SVGElement) => { if (this.props.isFragment === true) return; @@ -236,12 +276,16 @@ export class Graph extends React.Component { return linkBelowCell; } - drawGraph = (scenarioGraph: ScenarioGraph, layout: Layout, processDefinitionData: ProcessDefinitionData): void => { + drawGraph = ( + scenarioGraph: ScenarioGraph, + stickyNotes: StickyNote[], + layout: Layout, + processDefinitionData: ProcessDefinitionData, + ): void => { const { theme } = this.props; this.redrawing = true; - - applyCellChanges(this.processGraphPaper, scenarioGraph, processDefinitionData, theme); + applyCellChanges(this.processGraphPaper, scenarioGraph, stickyNotes, processDefinitionData, theme); if (isEmpty(layout)) { this.forceLayout(); @@ -304,7 +348,7 @@ export class Graph extends React.Component { constructor(props: Props) { super(props); - this.graph = new dia.Graph(); + this.graph = new dia.Graph({}, { cellNamespace: nuGraphNamespace }); this.bindNodeRemove(); this.bindNodesMoving(); } @@ -337,8 +381,22 @@ export class Graph extends React.Component { } }; + const showStickyNoteTools = (cellView: dia.CellView) => { + cellView.showTools(); + }; + + const hideToolsOnBlankClick = (evt: dia.Event) => { + evt.preventDefault(); + this.processGraphPaper.hideTools(); + }; + const selectNode = (cellView: dia.CellView, evt: dia.Event) => { if (this.props.isFragment === true) return; + this.processGraphPaper.hideTools(); + if (isStickyNoteElement(cellView.model)) { + if (!this.props.isPristine) return; + showStickyNoteTools(cellView); + } if (this.props.nodeSelectionEnabled) { const nodeDataId = cellView.model.attributes.nodeData?.id; if (!nodeDataId) { @@ -353,7 +411,10 @@ export class Graph extends React.Component { } }; - this.processGraphPaper.on(Events.CELL_POINTERDOWN, handleGraphEvent(handleActionOnLongPress(showNodeDetails, selectNode), null)); + this.processGraphPaper.on( + Events.CELL_POINTERDOWN, + handleGraphEvent(handleActionOnLongPress(showNodeDetails, selectNode), null, (view) => !isStickyNoteElement(view.model)), + ); this.processGraphPaper.on( Events.LINK_POINTERDOWN, handleGraphEvent( @@ -362,16 +423,21 @@ export class Graph extends React.Component { ), ); - this.processGraphPaper.on(Events.CELL_POINTERCLICK, handleGraphEvent(null, selectNode)); + this.processGraphPaper.on( + Events.CELL_POINTERCLICK, + handleGraphEvent(null, selectNode, (view) => !isStickyNoteElement(view.model)), + ); this.processGraphPaper.on(Events.CELL_POINTERDBLCLICK, handleGraphEvent(null, showNodeDetails)); + this.processGraphPaper.on(Events.BLANK_POINTERCLICK, hideToolsOnBlankClick); this.hooverHandling(); } componentDidMount(): void { this.processGraphPaper = this.createPaper(); - this.drawGraph(this.props.scenario.scenarioGraph, this.props.layout, this.props.processDefinitionData); + this.drawGraph(this.props.scenario.scenarioGraph, this.props.stickyNotes, this.props.layout, this.props.processDefinitionData); this.processGraphPaper.unfreeze(); + this.processGraphPaper.hideTools(); this._prepareContentForExport(); // event handlers binding below. order sometimes matters @@ -414,6 +480,62 @@ export class Graph extends React.Component { this.highlightHoveredLink(); }); + this.graph.on(Events.CELL_RESIZED, (cell: dia.Element) => { + if (isStickyNoteElement(cell)) { + if (!this.props.isPristine) { + this.props.notifications.warn( + i18next.t("notification.warn.cannotResizeOnUnsavedVersion", "Save scenario before resizing sticky note"), + ); + return; + } + const updatedStickyNote = getStickyNoteCopyFromCell(this.props.stickyNotes, cell); + if (!updatedStickyNote) return; + const position = cell.get("position"); + const size = cell.get("size"); + // TODO move max width and height to some config? + const width = Math.max( + STICKY_NOTE_CONSTRAINTS.MIN_WIDTH, + Math.min(STICKY_NOTE_CONSTRAINTS.MAX_WIDTH, Math.round(size.width)), + ); + const height = Math.max( + STICKY_NOTE_CONSTRAINTS.MIN_HEIGHT, + Math.min(STICKY_NOTE_CONSTRAINTS.MAX_HEIGHT, Math.round(size.height)), + ); + updatedStickyNote.layoutData = { x: position.x, y: position.y }; + updatedStickyNote.dimensions = { width, height }; + this.updateStickyNote(this.props.scenario.name, this.props.scenario.processVersionId, updatedStickyNote); + } + }); + + this.graph.on(Events.CELL_CONTENT_UPDATED, (cell: dia.Element, content: string) => { + if (isStickyNoteElement(cell)) { + if (!this.props.isPristine) { + this.props.notifications.warn( + i18next.t("notification.warn.cannotUpdateOnUnsavedVersion", "Save scenario before updating sticky note"), + ); + return; + } + const updatedStickyNote = getStickyNoteCopyFromCell(this.props.stickyNotes, cell); + if (!updatedStickyNote) return; + if (updatedStickyNote.content == content) return; + updatedStickyNote.content = content; + this.updateStickyNote(this.props.scenario.name, this.props.scenario.processVersionId, updatedStickyNote); + } + }); + + this.graph.on(Events.CELL_DELETED, (cell: dia.Element) => { + if (isStickyNoteElement(cell)) { + if (!this.props.isPristine) { + this.props.notifications.warn( + i18next.t("notification.warn.cannotDeleteOnUnsavedVersion", "Save scenario before deleting sticky note"), + ); + return; + } + const noteId = Number(cell.get("noteId")); + this.deleteStickyNote(this.props.scenario.name, noteId); + } + }); + //we want to inject node during 'Drag and Drop' from toolbox this.graph.on(Events.ADD, (cell: dia.Element) => { if (isModelElement(cell)) { @@ -436,15 +558,41 @@ export class Graph extends React.Component { } } + addStickyNote(scenarioName: string, scenarioVersionId: number, position: Position): void { + if (this.props.isFragment === true) return; + const canAddStickyNote = this.props.capabilities.editFrontend; + if (canAddStickyNote) { + const dimensions = { width: STICKY_NOTE_CONSTRAINTS.DEFAULT_WIDTH, height: STICKY_NOTE_CONSTRAINTS.DEFAULT_HEIGHT }; + this.props.stickyNoteAdded(scenarioName, scenarioVersionId, position, dimensions); + } + } + + updateStickyNote(scenarioName: string, scenarioVersionId: number, stickyNote: StickyNote): void { + if (this.props.isFragment === true) return; + const canUpdateStickyNote = this.props.capabilities.editFrontend; + if (canUpdateStickyNote) { + this.props.stickyNoteUpdated(scenarioName, scenarioVersionId, stickyNote); + } + } + + deleteStickyNote(scenarioName: string, stickyNoteId: number): void { + if (this.props.isFragment === true) return; + const canUpdateStickyNote = this.props.capabilities.editFrontend; + if (canUpdateStickyNote) { + this.props.stickyNoteDeleted(scenarioName, stickyNoteId); + } + } + // eslint-disable-next-line react/no-deprecated componentWillUpdate(nextProps: Props): void { const processChanged = !isEqual(this.props.scenario.scenarioGraph, nextProps.scenario.scenarioGraph) || !isEqual(this.props.scenario.validationResult, nextProps.scenario.validationResult) || + !isEqual(this.props.stickyNotes, nextProps.stickyNotes) || !isEqual(this.props.layout, nextProps.layout) || !isEqual(this.props.processDefinitionData, nextProps.processDefinitionData); if (processChanged) { - this.drawGraph(nextProps.scenario.scenarioGraph, nextProps.layout, nextProps.processDefinitionData); + this.drawGraph(nextProps.scenario.scenarioGraph, nextProps.stickyNotes, nextProps.layout, nextProps.processDefinitionData); } //when e.g. layout changed we have to remember to highlight nodes diff --git a/designer/client/src/components/graph/GraphPartialsInTS/applyCellChanges.ts b/designer/client/src/components/graph/GraphPartialsInTS/applyCellChanges.ts index d09c02db54d..ccb78ecb7b6 100644 --- a/designer/client/src/components/graph/GraphPartialsInTS/applyCellChanges.ts +++ b/designer/client/src/components/graph/GraphPartialsInTS/applyCellChanges.ts @@ -7,26 +7,32 @@ import NodeUtils from "../NodeUtils"; import { isEdgeConnected } from "./EdgeUtils"; import { updateChangedCells } from "./updateChangedCells"; import { Theme } from "@mui/material"; +import { StickyNote } from "../../../common/StickyNote"; +import { makeStickyNoteElement, ModelWithTool } from "../EspNode/stickyNoteElements"; export function applyCellChanges( paper: dia.Paper, scenarioGraph: ScenarioGraph, + stickyNotes: StickyNote[], processDefinitionData: ProcessDefinitionData, theme: Theme, ): void { const graph = paper.model; const nodeElements = NodeUtils.nodesFromScenarioGraph(scenarioGraph).map(makeElement(processDefinitionData, theme)); + const stickyNotesModelsWithTools: ModelWithTool[] = stickyNotes.map(makeStickyNoteElement(processDefinitionData, theme)); + const stickyNotesModels = stickyNotesModelsWithTools.map((a) => a.model); const edges = NodeUtils.edgesFromScenarioGraph(scenarioGraph); const indexed = flatMap(groupBy(edges, "from"), (edges) => edges.map((edge, i) => ({ ...edge, index: ++i }))); const edgeElements = indexed.filter(isEdgeConnected).map((value) => makeLink(value, paper, theme)); - const cells = [...nodeElements, ...edgeElements]; + const cells = [...nodeElements, ...edgeElements, ...stickyNotesModels]; const currentCells = graph.getCells(); const currentIds = currentCells.map((c) => c.id); const newCells = cells.filter((cell) => !currentIds.includes(cell.id)); + const newStickyNotesModelsWithTools = stickyNotesModelsWithTools.filter((s) => !currentIds.includes(s.model.id)); const deletedCells = currentCells.filter((oldCell) => !cells.find((cell) => cell.id === oldCell.id)); const changedCells = cells.filter((cell) => { const old = graph.getCell(cell.id); @@ -36,4 +42,17 @@ export function applyCellChanges( graph.removeCells(deletedCells); updateChangedCells(graph, changedCells); graph.addCells(newCells); + + newStickyNotesModelsWithTools.forEach((m) => { + try { + const view = m.model.findView(paper); + if (!view) { + console.warn(`View not found for stickyNote model: ${m.model.id}`); + return; + } + view.addTools(m.tools); + } catch (error) { + console.error(`Failed to add tools to stickyNote view:`, error); + } + }); } diff --git a/designer/client/src/components/graph/GraphPartialsInTS/cellUtils.ts b/designer/client/src/components/graph/GraphPartialsInTS/cellUtils.ts index 4723616cc14..896dcef5d82 100644 --- a/designer/client/src/components/graph/GraphPartialsInTS/cellUtils.ts +++ b/designer/client/src/components/graph/GraphPartialsInTS/cellUtils.ts @@ -1,4 +1,6 @@ import { dia, shapes } from "jointjs"; +import { StickyNote } from "../../../common/StickyNote"; +import { cloneDeep } from "lodash"; export const isLink = (c: dia.Cell): c is dia.Link => c.isLink(); export const isElement = (c: dia.Cell): c is dia.Element => c?.isElement(); @@ -7,6 +9,17 @@ export function isModelElement(el: dia.Cell): el is shapes.devs.Model { return el instanceof shapes.devs.Model; } +export function isStickyNoteElement(el: dia.Cell): el is shapes.devs.Model { + return isElement(el) && el.get("type") === `stickyNote.StickyNoteElement`; +} + +export function getStickyNoteCopyFromCell(stickyNotes: StickyNote[], el: dia.Cell): StickyNote | undefined { + const noteId = el.get("noteId"); + if (!isStickyNoteElement(el) || !noteId) return undefined; + const stickyNote = stickyNotes.find((note) => note.noteId == noteId); + return stickyNote ? cloneDeep(stickyNote) : undefined; +} + export function isConnected(el: dia.Element): boolean { return el.graph.getNeighbors(el).length > 0; } diff --git a/designer/client/src/components/graph/GraphWrapped.tsx b/designer/client/src/components/graph/GraphWrapped.tsx index d9b5351e844..4847d6123d7 100644 --- a/designer/client/src/components/graph/GraphWrapped.tsx +++ b/designer/client/src/components/graph/GraphWrapped.tsx @@ -1,7 +1,7 @@ import { useTheme } from "@mui/material"; import React, { forwardRef, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { useForkRef } from "rooks"; import { useEventTracking } from "../../containers/event-tracking"; import { getProcessCategory, getSelectionState, isPristine } from "../../reducers/selectors/graph"; @@ -12,10 +12,13 @@ import { Graph } from "./Graph"; import { GraphStyledWrapper } from "./graphStyledWrapper"; import { NodeDescriptionPopover } from "./NodeDescriptionPopover"; import { GraphProps } from "./types"; +import { bindActionCreators } from "redux"; +import * as NotificationActions from "../../actions/notificationActions"; // Graph wrapped to make partial (for now) refactor to TS and hooks export default forwardRef(function GraphWrapped(props, forwardedRef): JSX.Element { const { openNodeWindow } = useWindows(); + const dispatch = useDispatch(); const userSettings = useSelector(getUserSettings); const pristine = useSelector(isPristine); const processCategory = useSelector(getProcessCategory); @@ -25,7 +28,7 @@ export default forwardRef(function GraphWrapped(props, forwar const theme = useTheme(); const translation = useTranslation(); const { trackEvent } = useEventTracking(); - + const notifications = bindActionCreators(NotificationActions, dispatch); const graphRef = useRef(); const ref = useForkRef(graphRef, forwardedRef); @@ -45,6 +48,7 @@ export default forwardRef(function GraphWrapped(props, forwar theme={theme} translation={translation} handleStatisticsEvent={trackEvent} + notifications={notifications} /> diff --git a/designer/client/src/components/graph/NodeDescriptionPopover.tsx b/designer/client/src/components/graph/NodeDescriptionPopover.tsx index 3bf06feba19..ec6cc328045 100644 --- a/designer/client/src/components/graph/NodeDescriptionPopover.tsx +++ b/designer/client/src/components/graph/NodeDescriptionPopover.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { Graph } from "./Graph"; import { MarkdownStyled } from "./node-modal/MarkdownStyled"; import { Events } from "./types"; +import { isStickyNoteElement } from "./GraphPartialsInTS"; const useTimeout = >( callback: (...args: A) => void, @@ -119,6 +120,7 @@ export function NodeDescriptionPopover(props: NodeDescriptionPopoverProps) { const lastTarget = useRef(null); const enterTimer = useTimeout((view: dia.CellView, el: Element) => { + if (isStickyNoteElement(view.model)) return; //Dont use it for stickyNotes setData( el ? [t("graph.node.counts.title", "number of messages that passed downstream"), el] diff --git a/designer/client/src/components/graph/ProcessGraph.tsx b/designer/client/src/components/graph/ProcessGraph.tsx index 097c02daac2..771dab0f911 100644 --- a/designer/client/src/components/graph/ProcessGraph.tsx +++ b/designer/client/src/components/graph/ProcessGraph.tsx @@ -3,17 +3,29 @@ import { g } from "jointjs"; import { mapValues } from "lodash"; import { useDrop } from "react-dnd"; import { useDispatch, useSelector } from "react-redux"; -import { getScenario, getLayout, getProcessCounts } from "../../reducers/selectors/graph"; +import { getScenario, getLayout, getProcessCounts, getStickyNotes } from "../../reducers/selectors/graph"; import { setLinksHovered } from "./utils/dragHelpers"; import { Graph } from "./Graph"; import GraphWrapped from "./GraphWrapped"; import { RECT_HEIGHT, RECT_WIDTH } from "./EspNode/esp"; import NodeUtils from "./NodeUtils"; import { DndTypes } from "../toolbars/creator/Tool"; -import { injectNode, layoutChanged, nodeAdded, nodesConnected, nodesDisconnected, resetSelection, toggleSelection } from "../../actions/nk"; +import { + injectNode, + layoutChanged, + nodeAdded, + nodesConnected, + nodesDisconnected, + resetSelection, + stickyNoteAdded, + stickyNoteUpdated, + stickyNoteDeleted, + toggleSelection, +} from "../../actions/nk"; import { NodeType } from "../../types"; import { Capabilities } from "../../reducers/selectors/other"; import { bindActionCreators } from "redux"; +import { StickyNoteType } from "../../types/stickyNote"; export const ProcessGraph = forwardRef(function ProcessGraph( { capabilities }, @@ -21,6 +33,7 @@ export const ProcessGraph = forwardRef(fu ): JSX.Element { const scenario = useSelector(getScenario); const processCounts = useSelector(getProcessCounts); + const stickyNotes = useSelector(getStickyNotes); const layout = useSelector(getLayout); const graph = useRef(); @@ -33,8 +46,12 @@ export const ProcessGraph = forwardRef(fu const relOffset = graph.current.processGraphPaper.clientToLocalPoint(clientOffset); // to make node horizontally aligned const nodeInputRelOffset = relOffset.offset(RECT_WIDTH * -0.8, RECT_HEIGHT * -0.5); - graph.current.addNode(monitor.getItem(), mapValues(nodeInputRelOffset, Math.round)); - setLinksHovered(graph.current.graph); + if (item?.type === StickyNoteType) { + graph.current.addStickyNote(scenario.name, scenario.processVersionId, mapValues(nodeInputRelOffset, Math.round)); + } else { + graph.current.addNode(monitor.getItem(), mapValues(nodeInputRelOffset, Math.round)); + setLinksHovered(graph.current.graph); + } }, hover: (item: NodeType, monitor) => { const node = item; @@ -67,6 +84,9 @@ export const ProcessGraph = forwardRef(fu layoutChanged, injectNode, nodeAdded, + stickyNoteAdded, + stickyNoteUpdated, + stickyNoteDeleted, resetSelection, toggleSelection, }, @@ -81,6 +101,7 @@ export const ProcessGraph = forwardRef(fu connectDropTarget={connectDropTarget} isDraggingOver={isDraggingOver} capabilities={capabilities} + stickyNotes={stickyNotes} divId={"nk-graph-main"} nodeSelectionEnabled scenario={scenario} diff --git a/designer/client/src/components/graph/StickyNoteElement.ts b/designer/client/src/components/graph/StickyNoteElement.ts new file mode 100644 index 00000000000..9d59f7035de --- /dev/null +++ b/designer/client/src/components/graph/StickyNoteElement.ts @@ -0,0 +1,52 @@ +import { dia } from "jointjs"; +import { Events } from "./types"; +import { MARKDOWN_EDITOR_NAME } from "./EspNode/stickyNote"; +import MarkupNodeJSON = dia.MarkupNodeJSON; + +export interface StickyNoteDefaults { + position?: { x: number; y: number }; + size?: { width: number; height: number }; + attrs?: Record; +} + +export interface StickyNoteProtoProps { + markup: (dia.MarkupNodeJSON | MarkupNodeJSON)[]; + [key: string]: unknown; +} + +export const StickyNoteElement = (defaults?: StickyNoteDefaults, protoProps?: StickyNoteProtoProps) => + dia.Element.define("stickyNote.StickyNoteElement", defaults, protoProps); + +export const StickyNoteElementView = dia.ElementView.extend({ + events: { + "click .sticky-note-markdown-editor": "stopPropagation", + "keydown textarea": "selectAll", + "focusout .sticky-note-markdown-editor": "onChange", + "dblclick .sticky-note-content": "showEditor", + }, + + stopPropagation: function (evt) { + evt.stopPropagation(); + }, + + showEditor: function (evt) { + evt.stopPropagation(); + this.model.attr(`${MARKDOWN_EDITOR_NAME}/props/disabled`, false); + evt.currentTarget.querySelector("textarea").focus({ preventScroll: true }); + }, + + selectAll: function (evt) { + if (evt.code === "KeyA") { + if (evt.ctrlKey || evt.metaKey) { + evt.preventDefault(); + evt.target.select(); + } + } + }, + + onChange: function (evt) { + this.model.trigger(Events.CELL_CONTENT_UPDATED, this.model, evt.target.value); + this.model.attr(`${MARKDOWN_EDITOR_NAME}/props/value`, evt.target.value); + this.model.attr(`${MARKDOWN_EDITOR_NAME}/props/disabled`, true); + }, +}); diff --git a/designer/client/src/components/graph/fragmentGraph.tsx b/designer/client/src/components/graph/fragmentGraph.tsx index 645a696d21d..4694935ee44 100644 --- a/designer/client/src/components/graph/fragmentGraph.tsx +++ b/designer/client/src/components/graph/fragmentGraph.tsx @@ -4,19 +4,21 @@ import React, { forwardRef } from "react"; import { Graph } from "./Graph"; import { GraphProps } from "./types"; -export const FragmentGraphPreview = forwardRef>( - function FragmentGraphPreview({ processCounts, scenario, nodeIdPrefixForFragmentTests }, ref) { - return ( - - ); - }, -); +export const FragmentGraphPreview = forwardRef< + Graph, + Pick +>(function FragmentGraphPreview({ processCounts, scenario, stickyNotes, nodeIdPrefixForFragmentTests }, ref) { + return ( + + ); +}); diff --git a/designer/client/src/components/graph/graphStyledWrapper.ts b/designer/client/src/components/graph/graphStyledWrapper.ts index c0ab9fb6fee..3a6afcffa39 100644 --- a/designer/client/src/components/graph/graphStyledWrapper.ts +++ b/designer/client/src/components/graph/graphStyledWrapper.ts @@ -1,5 +1,5 @@ import { CSSProperties } from "react"; -import { css, styled, Theme } from "@mui/material"; +import { alpha, css, styled, Theme } from "@mui/material"; import { blend } from "@mui/system"; import { blendLighten } from "../../containers/theme/helpers"; @@ -181,6 +181,41 @@ export const GraphStyledWrapper = styled("div")(({ theme }) => transition: "all 0.25s ease-in-out", }, }, + ".sticky-note-markdown": { + width: "100%", + height: "100%", + paddingLeft: "10px", + paddingRight: "10px", + }, + ".sticky-note-markdown-editor": { + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + backgroundColor: alpha(theme.palette.common.white, 0.3), + color: theme.palette.common.black, + fontFamily: theme.typography.fontFamily, + fontSize: theme.typography.body1.fontSize, + resize: "none", + width: "100%", + height: "100%", + borderStyle: "none", + borderColor: "Transparent", + whiteSpace: "pre-line", + overflow: "hidden", + }, + ".sticky-note-markdown-editor:focus": { + outline: "none", + boxShadow: `0 0 0 2px ${theme.palette.primary.main}`, + }, + ".sticky-note-content": { + width: "100%", + height: "100%", + }, + ".joint-sticky-note-remove-tool > circle": { + fill: "#ca344c", + }, + ".sticky-note-markdown-editor:disabled": { + display: "none", + }, }, ]), ); diff --git a/designer/client/src/components/graph/node-modal/node/FragmentContent.tsx b/designer/client/src/components/graph/node-modal/node/FragmentContent.tsx index b96d03547ac..c3fe66b3c60 100644 --- a/designer/client/src/components/graph/node-modal/node/FragmentContent.tsx +++ b/designer/client/src/components/graph/node-modal/node/FragmentContent.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useState } from "react"; import { useSelector } from "react-redux"; import HttpService from "../../../../http/HttpService"; -import { getProcessCounts } from "../../../../reducers/selectors/graph"; +import { getProcessCounts, getStickyNotes } from "../../../../reducers/selectors/graph"; import { FragmentNodeType } from "../../../../types"; import { ErrorBoundary, DialogErrorFallbackComponent } from "../../../common/error-boundary"; import NodeUtils from "../../NodeUtils"; @@ -14,6 +14,7 @@ import { Scenario } from "../../../Process/types"; export function FragmentContent({ nodeToDisplay }: { nodeToDisplay: FragmentNodeType }): JSX.Element { const processCounts = useSelector(getProcessCounts); + const stickyNotes = useSelector(getStickyNotes); const processDefinitionData = useSelector(getProcessDefinitionData); const [fragmentContent, setFragmentContent] = useState(null); @@ -40,6 +41,7 @@ export function FragmentContent({ nodeToDisplay }: { nodeToDisplay: FragmentNode )} diff --git a/designer/client/src/components/graph/types.ts b/designer/client/src/components/graph/types.ts index ed27ac699fd..d5b6279b82c 100644 --- a/designer/client/src/components/graph/types.ts +++ b/designer/client/src/components/graph/types.ts @@ -7,10 +7,14 @@ import { nodesConnected, nodesDisconnected, resetSelection, + stickyNoteAdded, + stickyNoteDeleted, + stickyNoteUpdated, toggleSelection, } from "../../actions/nk"; import { Capabilities } from "../../reducers/selectors/other"; import { Scenario } from "../Process/types"; +import { StickyNote } from "../../common/StickyNote"; type ScenarioGraphProps = { nodesConnected: typeof nodesConnected; @@ -18,9 +22,13 @@ type ScenarioGraphProps = { layoutChanged: typeof layoutChanged; injectNode: typeof injectNode; nodeAdded: typeof nodeAdded; + stickyNoteAdded: typeof stickyNoteAdded; + stickyNoteUpdated: typeof stickyNoteUpdated; + stickyNoteDeleted: typeof stickyNoteDeleted; resetSelection: typeof resetSelection; toggleSelection: typeof toggleSelection; + stickyNotes: StickyNote[]; scenario: Scenario; divId: string; nodeIdPrefixForFragmentTests?: string; @@ -38,6 +46,7 @@ type ScenarioGraphProps = { type FragmentGraphProps = { scenario: Scenario; + stickyNotes: StickyNote[]; divId: string; nodeIdPrefixForFragmentTests: string; processCounts: ProcessCounts; @@ -65,6 +74,9 @@ export enum Events { CELL_MOUSEENTER = "cell:mouseenter", CELL_MOUSELEAVE = "cell:mouseleave", CELL_MOVED = "cellCustom:moved", + CELL_RESIZED = "cellCustom:resized", + CELL_CONTENT_UPDATED = "cellCustom:contentUpdated", + CELL_DELETED = "cellCustom:deleted", BLANK_POINTERCLICK = "blank:pointerclick", BLANK_POINTERDOWN = "blank:pointerdown", BLANK_POINTERUP = "blank:pointerup", diff --git a/designer/client/src/components/graph/utils/graphUtils.ts b/designer/client/src/components/graph/utils/graphUtils.ts index 3da1cda198c..25c445b0431 100644 --- a/designer/client/src/components/graph/utils/graphUtils.ts +++ b/designer/client/src/components/graph/utils/graphUtils.ts @@ -127,8 +127,9 @@ export const handleGraphEvent = ( touchEvent: ((view: T, event: dia.Event) => void) | null, mouseEvent: ((view: T, event: dia.Event) => void) | null, + preventDefault: (view: T) => boolean = () => true, ) => (view: T, event: dia.Event) => { - event.preventDefault(); + if (preventDefault(view)) event.preventDefault(); return isTouchEvent(event) ? touchEvent?.(view, event) : mouseEvent?.(view, event); }; diff --git a/designer/client/src/components/toolbars/creator/ComponentIcon.tsx b/designer/client/src/components/toolbars/creator/ComponentIcon.tsx index 1b8341b5931..bd3c350151a 100644 --- a/designer/client/src/components/toolbars/creator/ComponentIcon.tsx +++ b/designer/client/src/components/toolbars/creator/ComponentIcon.tsx @@ -7,6 +7,7 @@ import { NodeType, ProcessDefinitionData } from "../../../types"; import { InlineSvg } from "../../SvgDiv"; import { Icon } from "./Icon"; import { createRoot } from "react-dom/client"; +import { StickyNoteType } from "../../../types/stickyNote"; let preloadedIndex = 0; const preloadBeImage = memoize((src: string): string | null => { @@ -22,6 +23,11 @@ const preloadBeImage = memoize((src: string): string | null => { return `#${id}`; }); +export const stickyNoteIconSrc = `/assets/components/${StickyNoteType}.svg`; +export function stickyNoteIcon(): string | null { + return preloadBeImage(stickyNoteIconSrc); +} + export function getComponentIconSrc(node: NodeType, { components }: ProcessDefinitionData): string | null { // missing type means that node is the fake properties component // TODO we should split properties node logic and normal components logic diff --git a/designer/client/src/components/toolbars/creator/StickyNoteComponent.tsx b/designer/client/src/components/toolbars/creator/StickyNoteComponent.tsx new file mode 100644 index 00000000000..23e2fc5760c --- /dev/null +++ b/designer/client/src/components/toolbars/creator/StickyNoteComponent.tsx @@ -0,0 +1,19 @@ +import { StickyNoteType } from "../../../types/stickyNote"; +import { ComponentGroup } from "../../../types"; + +const noteModel = { id: "StickyNoteToAdd", type: StickyNoteType, isDisabled: false }; +export const stickyNoteComponentGroup = (pristine: boolean) => { + return [ + { + components: [ + { + node: noteModel, + label: "sticky note", + componentId: StickyNoteType + "_" + pristine, + disabled: () => !pristine, + }, + ], + name: "sticky notes", + } as ComponentGroup, + ]; +}; diff --git a/designer/client/src/components/toolbars/creator/ToolBox.tsx b/designer/client/src/components/toolbars/creator/ToolBox.tsx index def6637eb36..f7217a53d02 100644 --- a/designer/client/src/components/toolbars/creator/ToolBox.tsx +++ b/designer/client/src/components/toolbars/creator/ToolBox.tsx @@ -1,15 +1,18 @@ -import { lighten, styled } from "@mui/material"; -import { getLuminance } from "@mui/system/colorManipulator"; import React, { useMemo } from "react"; -import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; import "react-treeview/react-treeview.css"; import { filterComponentsByLabel } from "../../../common/ProcessDefinitionUtils"; -import { blendDarken, blendLighten } from "../../../containers/theme/helpers"; -import { getProcessDefinitionData } from "../../../reducers/selectors/settings"; +import { getProcessDefinitionData, getStickyNotesSettings } from "../../../reducers/selectors/settings"; import { ComponentGroup } from "../../../types"; import Tool from "./Tool"; import { ToolboxComponentGroup } from "./ToolboxComponentGroup"; +import { useTranslation } from "react-i18next"; +import { lighten, styled } from "@mui/material"; +import { blendDarken, blendLighten } from "../../../containers/theme/helpers"; +import { getLuminance } from "@mui/system/colorManipulator"; +import { isPristine } from "../../../reducers/selectors/graph"; +import { concat } from "lodash"; +import { stickyNoteComponentGroup } from "./StickyNoteComponent"; const StyledToolbox = styled("div")(({ theme }) => ({ fontSize: "14px", @@ -112,16 +115,17 @@ type ToolBoxProps = { export default function ToolBox(props: ToolBoxProps): JSX.Element { const processDefinitionData = useSelector(getProcessDefinitionData); + const stickyNotesSettings = useSelector(getStickyNotesSettings); + const pristine = useSelector(isPristine); const { t } = useTranslation(); const componentGroups: ComponentGroup[] = useMemo(() => processDefinitionData.componentGroups, [processDefinitionData]); - const filters = useMemo(() => props.filter?.toLowerCase().split(/\s/).filter(Boolean), [props.filter]); - - const groups = useMemo( - () => componentGroups.map(filterComponentsByLabel(filters)).filter((g) => g.components.length > 0), - [componentGroups, filters], - ); + const stickyNoteToolGroup = useMemo(() => stickyNoteComponentGroup(pristine), [pristine, props, t]); + const groups = useMemo(() => { + const allComponentGroups = stickyNotesSettings.enabled ? concat(componentGroups, stickyNoteToolGroup) : componentGroups; + return allComponentGroups.map(filterComponentsByLabel(filters)).filter((g) => g.components.length > 0); + }, [componentGroups, filters, stickyNoteToolGroup, stickyNotesSettings]); return ( diff --git a/designer/client/src/components/toolbars/creator/ToolboxComponentGroup.tsx b/designer/client/src/components/toolbars/creator/ToolboxComponentGroup.tsx index 4397aee00e1..966e138f255 100644 --- a/designer/client/src/components/toolbars/creator/ToolboxComponentGroup.tsx +++ b/designer/client/src/components/toolbars/creator/ToolboxComponentGroup.tsx @@ -73,7 +73,13 @@ export function ToolboxComponentGroup(props: Props): JSX.Element { const elements = useMemo( () => componentGroup.components.map((component) => ( - + )), [highlights, componentGroup.components], ); diff --git a/designer/client/src/containers/theme/helpers.ts b/designer/client/src/containers/theme/helpers.ts index 8f6c16fe3d3..9f3cab9a602 100644 --- a/designer/client/src/containers/theme/helpers.ts +++ b/designer/client/src/containers/theme/helpers.ts @@ -1,6 +1,7 @@ import { rgbToHex, Theme } from "@mui/material"; import { blend } from "@mui/system"; import { getLuminance } from "@mui/system/colorManipulator"; +import { STICKY_NOTE_DEFAULT_COLOR } from "../../components/graph/EspNode/stickyNote"; export const blendDarken = (color: string, opacity: number) => rgbToHex(blend(color, "#000000", opacity)); export const blendLighten = (color: string, opacity: number) => rgbToHex(blend(color, "#ffffff", opacity)); @@ -14,3 +15,12 @@ export function getNodeBorderColor(theme: Theme) { ? blendDarken(theme.palette.background.paper, 0.4) : blendLighten(theme.palette.background.paper, 0.6); } + +export function getStickyNoteBackgroundColor(theme: Theme, color: string) { + const isValidColor = CSS.supports("color", color); + return theme.palette.augmentColor({ + color: { + main: isValidColor ? color : STICKY_NOTE_DEFAULT_COLOR, + }, + }); +} diff --git a/designer/client/src/containers/theme/nuTheme.tsx b/designer/client/src/containers/theme/nuTheme.tsx index fc837aee834..3ca43d5c420 100644 --- a/designer/client/src/containers/theme/nuTheme.tsx +++ b/designer/client/src/containers/theme/nuTheme.tsx @@ -165,6 +165,7 @@ export const nuTheme = (mode: PaletteMode, setMode: Dispatch ({ backgroundColor: theme.palette.warning.main, + color: blendDarken(theme.palette.text.primary, 0.9), }), standardInfo: ({ theme }) => ({ backgroundColor: theme.palette.primary.main, diff --git a/designer/client/src/http/HttpService.ts b/designer/client/src/http/HttpService.ts index ae65597f9d5..9fcf4cdadc6 100644 --- a/designer/client/src/http/HttpService.ts +++ b/designer/client/src/http/HttpService.ts @@ -3,7 +3,7 @@ import { AxiosError, AxiosResponse } from "axios"; import FileSaver from "file-saver"; import i18next from "i18next"; import { Moment } from "moment"; -import { ProcessingType, SettingsData, ValidationData, ValidationRequest } from "../actions/nk"; +import { Position, ProcessingType, SettingsData, ValidationData, ValidationRequest } from "../actions/nk"; import { GenericValidationRequest, TestAdhocValidationRequest } from "../actions/nk/adhocTesting"; import api from "../api"; import { UserData } from "../common/models/User"; @@ -33,10 +33,12 @@ import { EventTrackingSelectorType, EventTrackingType } from "../containers/even import { BackendNotification } from "../containers/Notifications"; import { ProcessCounts } from "../reducers/graph"; import { AuthenticationSettings } from "../reducers/settings"; -import { Expression, NodeType, ProcessAdditionalFields, ProcessDefinitionData, ScenarioGraph, VariableTypes } from "../types"; +import { Expression, LayoutData, NodeType, ProcessAdditionalFields, ProcessDefinitionData, ScenarioGraph, VariableTypes } from "../types"; import { Instant, WithId } from "../types/common"; import { fixAggregateParameters, fixBranchParametersTemplate } from "./parametersUtils"; import { handleAxiosError } from "../devHelpers"; +import { Dimensions, StickyNote } from "../common/StickyNote"; +import { STICKY_NOTE_DEFAULT_COLOR } from "../components/graph/EspNode/stickyNote"; type HealthCheckProcessDeploymentType = { status: string; @@ -124,9 +126,10 @@ export type ComponentUsageType = { lastAction: ProcessActionType; }; -type NotificationActions = { +export type NotificationActions = { success(message: string): void; error(message: string, error: string, showErrorText: boolean): void; + warn(message: string): void; }; export interface TestProcessResponse { @@ -682,6 +685,57 @@ class HttpService { return promise; } + addStickyNote(scenarioName: string, scenarioVersionId: number, position: Position, dimensions: Dimensions) { + const promise = api.post(`/processes/${encodeURIComponent(scenarioName)}/stickyNotes`, { + scenarioVersionId, + content: "", + layoutData: position, + color: STICKY_NOTE_DEFAULT_COLOR, //TODO add config for default sticky note color? For now this is default. + dimensions: dimensions, + }); + promise.catch((error) => { + const errorMsg: string = error?.response?.data; + this.#addError("Failed to add sticky note" + (errorMsg ? ": " + errorMsg : ""), error, true); + }); + return promise; + } + + deleteStickyNote(scenarioName: string, stickyNoteId: number) { + const promise = api.delete(`/processes/${encodeURIComponent(scenarioName)}/stickyNotes/${stickyNoteId}`); + promise.catch((error) => + this.#addError( + i18next.t("notification.error.failedToDeleteStickyNote", `Failed to delete sticky note with id: ${stickyNoteId}`), + error, + true, + ), + ); + return promise; + } + + updateStickyNote(scenarioName: string, scenarioVersionId: number, stickyNote: StickyNote) { + const promise = api.put(`/processes/${encodeURIComponent(scenarioName)}/stickyNotes`, { + noteId: stickyNote.noteId, + scenarioVersionId, + content: stickyNote.content, + layoutData: stickyNote.layoutData, + color: stickyNote.color, + dimensions: stickyNote.dimensions, + }); + promise.catch((error) => { + const errorMsg = error?.response?.data; + this.#addError("Failed to update sticky note" + errorMsg ? ": " + errorMsg : "", error, true); + }); + return promise; + } + + getStickyNotes(scenarioName: string, scenarioVersionId: number): Promise> { + const promise = api.get(`/processes/${encodeURIComponent(scenarioName)}/stickyNotes?scenarioVersionId=${scenarioVersionId}`); + promise.catch((error) => + this.#addError(i18next.t("notification.error.failedToGetStickyNotes", "Failed to get sticky notes"), error, true), + ); + return promise; + } + fetchProcessCounts(processName: string, dateFrom: Moment, dateTo: Moment): Promise> { //we use offset date time instead of timestamp to pass info about user time zone to BE const format = (date: Moment) => date?.format("YYYY-MM-DDTHH:mm:ssZ"); diff --git a/designer/client/src/reducers/graph/reducer.ts b/designer/client/src/reducers/graph/reducer.ts index b5bbaacafd9..333cfd8d70f 100644 --- a/designer/client/src/reducers/graph/reducer.ts +++ b/designer/client/src/reducers/graph/reducer.ts @@ -16,9 +16,12 @@ import { selectionState } from "./selectionState"; import { GraphState } from "./types"; import { addNodesWithLayout, + addStickyNotesWithLayout, adjustBranchParametersAfterDisconnect, createEdge, enrichNodeWithProcessDependentData, + prepareNewStickyNotesWithLayout, + removeStickyNoteFromLayout, updateAfterNodeDelete, updateLayoutAfterNodeIdChange, } from "./utils"; @@ -238,6 +241,18 @@ const graphReducer: Reducer = (state = emptyGraphState, action) => { layout: action.layout, }); } + case "STICKY_NOTES_UPDATED": { + const { stickyNotes, layout } = prepareNewStickyNotesWithLayout(state, action.stickyNotes); + return { + ...addStickyNotesWithLayout(state, { stickyNotes, layout }), + }; + } + case "STICKY_NOTE_DELETED": { + const { stickyNotes, layout } = removeStickyNoteFromLayout(state, action.stickyNoteId); + return { + ...addStickyNotesWithLayout(state, { stickyNotes, layout }), + }; + } case "NODES_WITH_EDGES_ADDED": { const { nodes, layout, idMapping, processDefinitionData, edges } = action; diff --git a/designer/client/src/reducers/graph/types.ts b/designer/client/src/reducers/graph/types.ts index 4f4002057f5..f22bd1e4623 100644 --- a/designer/client/src/reducers/graph/types.ts +++ b/designer/client/src/reducers/graph/types.ts @@ -1,6 +1,7 @@ import { Layout, RefreshData } from "../../actions/nk"; import { Scenario } from "../../components/Process/types"; import { TestCapabilities, TestFormParameters, TestResults } from "../../common/TestResultUtils"; +import { StickyNote } from "../../common/StickyNote"; export interface NodeCounts { errors?: number; @@ -13,6 +14,7 @@ export type ProcessCounts = Record; export type GraphState = { scenarioLoading: boolean; scenario?: Scenario; + stickyNotes?: StickyNote[]; selectionState?: string[]; layout: Layout; testCapabilities?: TestCapabilities; diff --git a/designer/client/src/reducers/graph/utils.ts b/designer/client/src/reducers/graph/utils.ts index f19cbc50075..dbc2b175692 100644 --- a/designer/client/src/reducers/graph/utils.ts +++ b/designer/client/src/reducers/graph/utils.ts @@ -5,6 +5,8 @@ import { ExpressionLang } from "../../components/graph/node-modal/editors/expres import NodeUtils from "../../components/graph/NodeUtils"; import { BranchParams, Edge, EdgeType, NodeId, NodeType, ProcessDefinitionData } from "../../types"; import { GraphState } from "./types"; +import { StickyNote } from "../../common/StickyNote"; +import { createStickyNoteId } from "../../types/stickyNote"; export function updateLayoutAfterNodeIdChange(layout: Layout, oldId: NodeId, newId: NodeId): Layout { return map(layout, (n) => (oldId === n.id ? { ...n, id: newId } : n)); @@ -77,6 +79,33 @@ export function prepareNewNodesWithLayout( }; } +export function removeStickyNoteFromLayout(state: GraphState, stickyNoteId: number): { layout: NodePosition[]; stickyNotes: StickyNote[] } { + const { layout } = state; + const stickyNoteLayoutId = createStickyNoteId(stickyNoteId); + const updatedStickyNotes = state.stickyNotes.filter((n) => n.noteId !== stickyNoteId); + const updatedLayout = updatedStickyNotes.map((stickyNote) => { + return { id: stickyNote.id, position: stickyNote.layoutData }; + }); + return { + stickyNotes: [...updatedStickyNotes], + layout: [...layout.filter((l) => l.id !== stickyNoteLayoutId), ...updatedLayout], + }; +} + +export function prepareNewStickyNotesWithLayout( + state: GraphState, + stickyNotes: StickyNote[], +): { layout: NodePosition[]; stickyNotes: StickyNote[] } { + const { layout } = state; + const updatedLayout = stickyNotes.map((stickyNote) => { + return { id: createStickyNoteId(stickyNote.noteId), position: stickyNote.layoutData }; + }); + return { + stickyNotes: [...stickyNotes], + layout: [...layout, ...updatedLayout], + }; +} + export function addNodesWithLayout( state: GraphState, changes: { @@ -103,6 +132,17 @@ export function addNodesWithLayout( }; } +export function addStickyNotesWithLayout( + state: GraphState, + { stickyNotes, layout }: ReturnType, +): GraphState { + return { + ...state, + stickyNotes: stickyNotes, + layout, + }; +} + export function createEdge( fromNode: NodeType, toNode: NodeType, diff --git a/designer/client/src/reducers/selectors/graph.ts b/designer/client/src/reducers/selectors/graph.ts index 3fbf656d70a..473f3251439 100644 --- a/designer/client/src/reducers/selectors/graph.ts +++ b/designer/client/src/reducers/selectors/graph.ts @@ -8,6 +8,8 @@ import { ProcessCounts } from "../graph"; import { RootState } from "../index"; import { getProcessState } from "./scenarioState"; import { TestFormParameters } from "../../common/TestResultUtils"; +import { StickyNote } from "../../common/StickyNote"; +import { getStickyNotesSettings } from "./settings"; export const getGraph = (state: RootState) => state.graphReducer.history.present; @@ -75,6 +77,9 @@ export const getTestParameters = createSelector(getGraph, (g) => g.testFormParam export const getTestResults = createSelector(getGraph, (g) => g.testResults); export const getProcessCountsRefresh = createSelector(getGraph, (g) => g.processCountsRefresh || null); export const getProcessCounts = createSelector(getGraph, (g): ProcessCounts => g.processCounts || ({} as ProcessCounts)); +export const getStickyNotes = createSelector([getGraph, getStickyNotesSettings], (g, settings) => + settings.enabled ? g.stickyNotes || ([] as StickyNote[]) : ([] as StickyNote[]), +); export const getShowRunProcessDetails = createSelector( [getTestResults, getProcessCounts], (testResults, processCounts) => testResults || processCounts, diff --git a/designer/client/src/reducers/selectors/settings.ts b/designer/client/src/reducers/selectors/settings.ts index e222a76b8ae..382290a60ea 100644 --- a/designer/client/src/reducers/selectors/settings.ts +++ b/designer/client/src/reducers/selectors/settings.ts @@ -14,6 +14,7 @@ export const getEnvironmentAlert = createSelector(getFeatureSettings, (s) => s?. export const getTabs = createSelector(getFeatureSettings, (s): DynamicTabData[] => uniqBy(s.tabs || [], (t) => t.id)); export const getTargetEnvironmentId = createSelector(getFeatureSettings, (s) => s?.remoteEnvironment?.targetEnvironmentId); export const getSurveySettings = createSelector(getFeatureSettings, (s) => s?.surveySettings); +export const getStickyNotesSettings = createSelector(getFeatureSettings, (s) => s?.stickyNotesSettings); export const getLoggedUser = createSelector(getSettings, (s) => s.loggedUser); export const getLoggedUserId = createSelector(getLoggedUser, (s) => s.id); export const getProcessDefinitionData = createSelector(getSettings, (s) => s.processDefinitionData || ({} as ProcessDefinitionData)); diff --git a/designer/client/src/types/component.ts b/designer/client/src/types/component.ts index 6999e20af94..acd979bd43d 100644 --- a/designer/client/src/types/component.ts +++ b/designer/client/src/types/component.ts @@ -5,6 +5,7 @@ export type Component = { node: NodeType; label: string; componentId: string; + disabled?: () => boolean; }; export type ComponentGroup = { components: Component[]; diff --git a/designer/client/src/types/node.ts b/designer/client/src/types/node.ts index c021f21c87e..014a4c13670 100644 --- a/designer/client/src/types/node.ts +++ b/designer/client/src/types/node.ts @@ -1,7 +1,8 @@ import { ProcessAdditionalFields, ReturnedType } from "./scenarioGraph"; import { FragmentInputParameter } from "../components/graph/node-modal/fragment-input-definition/item"; +import { StickyNoteType } from "./stickyNote"; -type Type = "FragmentInput" | string; +type Type = "FragmentInput" | typeof StickyNoteType | string; export type LayoutData = { x: number; y: number }; diff --git a/designer/client/src/types/stickyNote.ts b/designer/client/src/types/stickyNote.ts new file mode 100644 index 00000000000..eb1758c2660 --- /dev/null +++ b/designer/client/src/types/stickyNote.ts @@ -0,0 +1,4 @@ +export const StickyNoteType = "StickyNote"; +export function createStickyNoteId(noteId: number) { + return StickyNoteType + "_" + noteId; +} diff --git a/designer/server/src/main/resources/defaultDesignerConfig.conf b/designer/server/src/main/resources/defaultDesignerConfig.conf index b6c2e5c724f..9084c19c84b 100644 --- a/designer/server/src/main/resources/defaultDesignerConfig.conf +++ b/designer/server/src/main/resources/defaultDesignerConfig.conf @@ -211,6 +211,12 @@ testDataSettings: { resultsMaxBytes: 50000000 } +stickyNotesSettings: { + maxContentLength: 5000, + maxNotesCount: 5 + enabled: true +} + scenarioLabelSettings: { validationRules = [ { diff --git a/designer/server/src/main/resources/web/static/assets/components/StickyNote.svg b/designer/server/src/main/resources/web/static/assets/components/StickyNote.svg new file mode 100644 index 00000000000..db4797cc376 --- /dev/null +++ b/designer/server/src/main/resources/web/static/assets/components/StickyNote.svg @@ -0,0 +1 @@ + diff --git a/designer/server/src/main/scala/db/migration/V1_060__CreateStickyNotesDefinition.scala b/designer/server/src/main/scala/db/migration/V1_060__CreateStickyNotesDefinition.scala new file mode 100644 index 00000000000..eef502e1b11 --- /dev/null +++ b/designer/server/src/main/scala/db/migration/V1_060__CreateStickyNotesDefinition.scala @@ -0,0 +1,89 @@ +package db.migration + +import com.typesafe.scalalogging.LazyLogging +import db.migration.V1_060__CreateStickyNotesDefinition.StickyNotesDefinitions +import pl.touk.nussknacker.ui.db.migration.SlickMigration +import slick.jdbc.JdbcProfile +import slick.sql.SqlProfile.ColumnOption.NotNull +import shapeless.syntax.std.tuple._ +import java.sql.Timestamp +import java.util.UUID +import scala.concurrent.ExecutionContext.Implicits.global + +trait V1_060__CreateStickyNotesDefinition extends SlickMigration with LazyLogging { + + import profile.api._ + + private val definitions = new StickyNotesDefinitions(profile) + + override def migrateActions: DBIOAction[Any, NoStream, Effect.All] = { + logger.info("Starting migration V1_060__CreateStickyNotesDefinition") + for { + _ <- definitions.stickyNotesEntityTable.schema.create + _ <- + sqlu"""ALTER TABLE "sticky_notes" ADD CONSTRAINT "sticky_notes_scenario_version_fk" FOREIGN KEY ("scenario_id", "scenario_version_id") REFERENCES "process_versions" ("process_id", "id") ON DELETE CASCADE;""" + } yield logger.info("Execution finished for migration V1_060__CreateStickyNotesDefinition") + } + +} + +object V1_060__CreateStickyNotesDefinition { + + class StickyNotesDefinitions(val profile: JdbcProfile) { + import profile.api._ + val stickyNotesEntityTable = TableQuery[StickyNotesEntity] + + class StickyNotesEntity(tag: Tag) extends Table[StickyNoteEventEntityData](tag, "sticky_notes") { + + def id = column[Long]("id", O.PrimaryKey, O.AutoInc) + def noteCorrelationId = column[UUID]("note_correlation_id", NotNull) + def content = column[String]("content", NotNull) + def layoutData = column[String]("layout_data", NotNull) + def color = column[String]("color", NotNull) + def dimensions = column[String]("dimensions", NotNull) + def targetEdge = column[Option[String]]("target_edge") + def eventCreator = column[String]("event_creator", NotNull) + def eventDate = column[Timestamp]("event_date", NotNull) + def eventType = column[String]("event_type", NotNull) + def scenarioId = column[Long]("scenario_id", NotNull) + def scenarioVersionId = column[Long]("scenario_version_id", NotNull) + + def tupleWithoutAutoIncId = ( + noteCorrelationId, + content, + layoutData, + color, + dimensions, + targetEdge, + eventCreator, + eventDate, + eventType, + scenarioId, + scenarioVersionId + ) + + override def * = + (id :: tupleWithoutAutoIncId.productElements).tupled <> ( + StickyNoteEventEntityData.apply _ tupled, StickyNoteEventEntityData.unapply + ) + + } + + } + + final case class StickyNoteEventEntityData( + id: Long, + noteCorrelationId: UUID, + content: String, + layoutData: String, + color: String, + dimensions: String, + targetEdge: Option[String], + eventCreator: String, + eventDate: Timestamp, + eventType: String, + scenarioId: Long, + scenarioVersionId: Long + ) + +} diff --git a/designer/server/src/main/scala/db/migration/hsql/V1_060__CreateStickyNotes.scala b/designer/server/src/main/scala/db/migration/hsql/V1_060__CreateStickyNotes.scala new file mode 100644 index 00000000000..86c5dd34568 --- /dev/null +++ b/designer/server/src/main/scala/db/migration/hsql/V1_060__CreateStickyNotes.scala @@ -0,0 +1,8 @@ +package db.migration.hsql + +import db.migration.V1_060__CreateStickyNotesDefinition +import slick.jdbc.{HsqldbProfile, JdbcProfile} + +class V1_060__CreateStickyNotes extends V1_060__CreateStickyNotesDefinition { + override protected lazy val profile: JdbcProfile = HsqldbProfile +} diff --git a/designer/server/src/main/scala/db/migration/postgres/V1_060__CreateStickyNotes.scala b/designer/server/src/main/scala/db/migration/postgres/V1_060__CreateStickyNotes.scala new file mode 100644 index 00000000000..e2887ece894 --- /dev/null +++ b/designer/server/src/main/scala/db/migration/postgres/V1_060__CreateStickyNotes.scala @@ -0,0 +1,8 @@ +package db.migration.postgres + +import db.migration.V1_060__CreateStickyNotesDefinition +import slick.jdbc.{JdbcProfile, PostgresProfile} + +class V1_060__CreateStickyNotes extends V1_060__CreateStickyNotesDefinition { + override protected lazy val profile: JdbcProfile = PostgresProfile +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/SettingsResources.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/SettingsResources.scala index 5c17a33ea9c..e2915233594 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/SettingsResources.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/SettingsResources.scala @@ -7,6 +7,7 @@ import io.circe.{Decoder, Encoder} import io.circe.generic.JsonCodec import pl.touk.nussknacker.ui.config.{FeatureTogglesConfig, UsageStatisticsReportsConfig} import pl.touk.nussknacker.engine.api.CirceUtil.codecs._ +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNotesSettings import pl.touk.nussknacker.ui.statistics.{Fingerprint, FingerprintService} import java.net.URL @@ -42,7 +43,8 @@ class SettingsResources( testDataSettings = config.testDataSettings, redirectAfterArchive = config.redirectAfterArchive, usageStatisticsReports = - UsageStatisticsReportsSettings(usageStatisticsReportsConfig, fingerprint.toOption) + UsageStatisticsReportsSettings(usageStatisticsReportsConfig, fingerprint.toOption), + stickyNotesSettings = config.stickyNotesSettings ) val authenticationSettings = AuthenticationSettings( authenticationMethod @@ -134,6 +136,7 @@ object TopTabType extends Enumeration { tabs: Option[List[TopTab]], intervalTimeSettings: IntervalTimeSettings, testDataSettings: TestDataSettings, + stickyNotesSettings: StickyNotesSettings, redirectAfterArchive: Boolean, usageStatisticsReports: UsageStatisticsReportsSettings, ) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpService.scala new file mode 100644 index 00000000000..0c29c5257c3 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpService.scala @@ -0,0 +1,230 @@ +package pl.touk.nussknacker.ui.api + +import cats.data.EitherT +import com.typesafe.scalalogging.LazyLogging +import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} +import pl.touk.nussknacker.security.Permission +import pl.touk.nussknacker.security.Permission.Permission +import pl.touk.nussknacker.ui.api.description.StickyNotesApiEndpoints +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{ + StickyNote, + StickyNoteAddRequest, + StickyNoteCorrelationId, + StickyNoteId, + StickyNoteUpdateRequest, + StickyNotesError, + StickyNotesSettings +} +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNotesError.{ + NoPermission, + NoScenario, + StickyNoteContentTooLong, + StickyNoteCountLimitReached +} +import pl.touk.nussknacker.ui.process.repository.stickynotes.StickyNotesRepository +import pl.touk.nussknacker.ui.process.repository.DBIOActionRunner +import pl.touk.nussknacker.ui.process.ProcessService +import pl.touk.nussknacker.ui.security.api.{AuthManager, LoggedUser} + +import scala.concurrent.{ExecutionContext, Future} + +class StickyNotesApiHttpService( + authManager: AuthManager, + stickyNotesRepository: StickyNotesRepository, + scenarioService: ProcessService, + scenarioAuthorizer: AuthorizeProcess, + dbioActionRunner: DBIOActionRunner, + stickyNotesSettings: StickyNotesSettings +)(implicit executionContext: ExecutionContext) + extends BaseHttpService(authManager) + with LazyLogging { + + private val securityInput = authManager.authenticationEndpointInput() + + private val endpoints = new StickyNotesApiEndpoints(securityInput) + + expose { + endpoints.stickyNotesGetEndpoint + .serverSecurityLogic(authorizeKnownUser[StickyNotesError]) + .serverLogicEitherT { implicit loggedUser => + { case (scenarioName, versionId) => + for { + scenarioId <- getScenarioIdByName(scenarioName) + _ <- isAuthorized(scenarioId, Permission.Read) + processActivity <- fetchStickyNotes(scenarioId, versionId) + } yield processActivity.toList + } + } + } + + expose { + endpoints.stickyNotesAddEndpoint + .serverSecurityLogic(authorizeKnownUser[StickyNotesError]) + .serverLogicEitherT { implicit loggedUser => + { case (scenarioName, requestBody) => + for { + scenarioId <- getScenarioIdByName(scenarioName) + _ <- isAuthorized(scenarioId, Permission.Write) + count <- getStickyNotesCount(scenarioId, requestBody.scenarioVersionId) + _ <- validateStickyNotesCount(count, stickyNotesSettings) + _ <- validateStickyNoteContent(requestBody.content, stickyNotesSettings) + processActivity <- addStickyNote(scenarioId, requestBody) + } yield processActivity + } + } + } + + expose { + endpoints.stickyNotesUpdateEndpoint + .serverSecurityLogic(authorizeKnownUser[StickyNotesError]) + .serverLogicEitherT { implicit loggedUser => + { case (scenarioName, requestBody) => + for { + scenarioId <- getScenarioIdByName(scenarioName) + _ <- isAuthorized(scenarioId, Permission.Write) + _ <- validateStickyNoteContent(requestBody.content, stickyNotesSettings) + processActivity <- updateStickyNote(requestBody) + } yield processActivity.toInt + } + } + } + + expose { + endpoints.stickyNotesDeleteEndpoint + .serverSecurityLogic(authorizeKnownUser[StickyNotesError]) + .serverLogicEitherT { implicit loggedUser => + { case (scenarioName, noteId) => + for { + scenarioId <- getScenarioIdByName(scenarioName) + _ <- isAuthorized(scenarioId, Permission.Write) + processActivity <- deleteStickyNote(noteId) + } yield processActivity.toInt + } + } + + } + + private def getScenarioIdByName(scenarioName: ProcessName) = { + EitherT.fromOptionF( + scenarioService.getProcessId(scenarioName), + NoScenario(scenarioName) + ) + } + + private def isAuthorized(scenarioId: ProcessId, permission: Permission)( + implicit loggedUser: LoggedUser + ): EitherT[Future, StickyNotesError, Unit] = + EitherT( + scenarioAuthorizer + .check(scenarioId, permission, loggedUser) + .map[Either[StickyNotesError, Unit]] { + case true => Right(()) + case false => Left(NoPermission) + } + ) + + private def fetchStickyNotes(scenarioId: ProcessId, versionId: VersionId)( + implicit loggedUser: LoggedUser + ): EitherT[Future, StickyNotesError, Seq[StickyNote]] = + EitherT + .right( + dbioActionRunner.run( + stickyNotesRepository.findStickyNotes(scenarioId, versionId) + ) + ) + + private def getStickyNotesCount(scenarioId: ProcessId, versionId: VersionId): EitherT[Future, StickyNotesError, Int] = + EitherT + .right( + dbioActionRunner + .run( + stickyNotesRepository.countStickyNotes(scenarioId, versionId) + ) + ) + + private def deleteStickyNote(noteId: StickyNoteId)( + implicit loggedUser: LoggedUser + ): EitherT[Future, StickyNotesError, Int] = + for { + note <- EitherT.fromOptionF( + dbioActionRunner.run( + stickyNotesRepository.findStickyNoteById(noteId) + ), + StickyNotesError.NoStickyNote(noteId) + ) + _ <- isAuthorized(note.scenarioId, Permission.Write) + result <- EitherT.right( + dbioActionRunner.run( + stickyNotesRepository.deleteStickyNote(noteId) + ) + ) + } yield result + + private def validateStickyNotesCount( + stickyNotesCount: Int, + stickyNotesConfig: StickyNotesSettings + ): EitherT[Future, StickyNotesError, Unit] = + EitherT.fromEither( + Either.cond( + stickyNotesCount < stickyNotesConfig.maxNotesCount, + (), + StickyNoteCountLimitReached(stickyNotesConfig.maxNotesCount) + ) + ) + + private def addStickyNote(scenarioId: ProcessId, requestBody: StickyNoteAddRequest)( + implicit loggedUser: LoggedUser + ): EitherT[Future, StickyNotesError, StickyNoteCorrelationId] = + EitherT + .right( + dbioActionRunner.run( + stickyNotesRepository.addStickyNote( + requestBody.content, + requestBody.layoutData, + requestBody.color, + requestBody.dimensions, + requestBody.targetEdge, + scenarioId, + requestBody.scenarioVersionId + ) + ) + ) + + private def validateStickyNoteContent( + content: String, + stickyNotesConfig: StickyNotesSettings + ): EitherT[Future, StickyNotesError, Unit] = + EitherT.fromEither( + Either.cond( + content.length <= stickyNotesConfig.maxContentLength, + (), + StickyNoteContentTooLong(content.length, stickyNotesConfig.maxContentLength) + ) + ) + + private def updateStickyNote(requestBody: StickyNoteUpdateRequest)( + implicit loggedUser: LoggedUser + ): EitherT[Future, StickyNotesError, Int] = for { + note <- EitherT.fromOptionF( + dbioActionRunner.run( + stickyNotesRepository.findStickyNoteById(requestBody.noteId) + ), + StickyNotesError.NoStickyNote(requestBody.noteId) + ) + _ <- isAuthorized(note.scenarioId, Permission.Write) + result <- EitherT.right( + dbioActionRunner.run( + stickyNotesRepository.updateStickyNote( + requestBody.noteId, + requestBody.content, + requestBody.layoutData, + requestBody.color, + requestBody.dimensions, + requestBody.targetEdge, + requestBody.scenarioVersionId + ) + ) + ) + } yield result + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/StickyNotesApiEndpoints.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/StickyNotesApiEndpoints.scala new file mode 100644 index 00000000000..4dff4ac3d0e --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/StickyNotesApiEndpoints.scala @@ -0,0 +1,219 @@ +package pl.touk.nussknacker.ui.api.description + +import io.circe.Encoder +import pl.touk.nussknacker.engine.api.LayoutData +import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId} +import pl.touk.nussknacker.engine.api.typed.typing._ +import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions +import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions.SecuredEndpoint +import pl.touk.nussknacker.security.AuthCredentials +import pl.touk.nussknacker.ui.api.TapirCodecs +import pl.touk.nussknacker.ui.api.TapirCodecs.ScenarioNameCodec._ +import pl.touk.nussknacker.ui.api.description.StickyNotesApiEndpoints.Examples.{ + noScenarioExample, + noStickyNoteExample, + stickyNoteContentTooLongExample, + stickyNoteCountLimitReachedExample +} +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNoteId +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNotesError.{ + NoScenario, + NoStickyNote, + StickyNoteContentTooLong, + StickyNoteCountLimitReached +} +import sttp.model.StatusCode.{BadRequest, NotFound, Ok} +import sttp.tapir.EndpointIO.Example +import sttp.tapir._ +import sttp.tapir.json.circe.jsonBody + +import java.time.Instant + +class StickyNotesApiEndpoints(auth: EndpointInput[AuthCredentials]) extends BaseEndpointDefinitions { + + import stickynotes.Dtos._ + import TapirCodecs.VersionIdCodec._ + + lazy val encoder: Encoder[TypingResult] = TypingResult.encoder + + private val exampleInstantDate = Instant.ofEpochMilli(1730136602) + + private val exampleStickyNote = StickyNote( + StickyNoteId(1), + "##Title \nNote1", + LayoutData(20, 30), + "#99aa20", + Dimensions(300, 200), + None, + "Marco", + exampleInstantDate + ) + + lazy val stickyNotesGetEndpoint: SecuredEndpoint[ + (ProcessName, VersionId), + StickyNotesError, + List[StickyNote], + Any + ] = { + baseNuApiEndpoint + .summary("Returns sticky nodes for given scenario with version") + .tag("StickyNotes") + .get + .in("processes" / path[ProcessName]("scenarioName") / "stickyNotes" / query[VersionId]("scenarioVersionId")) + .out( + statusCode(Ok).and( + jsonBody[List[StickyNote]] + .examples( + List( + Example.of( + summary = Some("List of valid sticky notes returned for scenario"), + value = List( + exampleStickyNote, + exampleStickyNote.copy(noteId = StickyNoteId(2)) + ) + ) + ) + ) + ) + ) + .errorOut( + oneOf[StickyNotesError]( + noScenarioExample + ) + ) + .withSecurity(auth) + } + + lazy val stickyNotesUpdateEndpoint + : SecuredEndpoint[(ProcessName, StickyNoteUpdateRequest), StickyNotesError, Unit, Any] = { + baseNuApiEndpoint + .summary("Updates sticky note with new values") + .tag("StickyNotes") + .put + .in("processes" / path[ProcessName]("scenarioName") / "stickyNotes") + .in( + jsonBody[StickyNoteUpdateRequest] + .example( + StickyNoteUpdateRequest( + StickyNoteId(1), + VersionId(1), + "", + LayoutData(12, 33), + "#441022", + Dimensions(300, 200), + None + ) + ) + ) + .out( + statusCode(Ok) + ) + .errorOut( + oneOf[StickyNotesError]( + noScenarioExample, + noStickyNoteExample, + stickyNoteContentTooLongExample + ) + ) + .withSecurity(auth) + } + + lazy val stickyNotesAddEndpoint + : SecuredEndpoint[(ProcessName, StickyNoteAddRequest), StickyNotesError, StickyNoteCorrelationId, Any] = { + baseNuApiEndpoint + .summary("Creates new sticky note with given content") + .tag("StickyNotes") + .post + .in("processes" / path[ProcessName]("scenarioName") / "stickyNotes") + .in( + jsonBody[StickyNoteAddRequest] + .example(StickyNoteAddRequest(VersionId(1), "", LayoutData(12, 33), "#441022", Dimensions(300, 200), None)) + ) + .out(jsonBody[StickyNoteCorrelationId]) + .errorOut( + oneOf[StickyNotesError]( + noScenarioExample, + stickyNoteContentTooLongExample, + stickyNoteCountLimitReachedExample + ) + ) + .withSecurity(auth) + } + + lazy val stickyNotesDeleteEndpoint: SecuredEndpoint[(ProcessName, StickyNoteId), StickyNotesError, Unit, Any] = { + baseNuApiEndpoint + .summary("Deletes stickyNote by given noteId") + .tag("StickyNotes") + .delete + .in("processes" / path[ProcessName]("scenarioName") / "stickyNotes" / path[StickyNoteId]("noteId")) + .out( + statusCode(Ok) + ) + .errorOut( + oneOf[StickyNotesError]( + noStickyNoteExample, + noScenarioExample + ) + ) + .withSecurity(auth) + } + +} + +object StickyNotesApiEndpoints { + + object Examples { + + val noScenarioExample: EndpointOutput.OneOfVariant[NoScenario] = + oneOfVariantFromMatchType( + NotFound, + plainBody[NoScenario] + .example( + Example.of( + summary = Some("No scenario {scenarioName} found"), + value = NoScenario(ProcessName("s1")) + ) + ) + ) + + val noStickyNoteExample: EndpointOutput.OneOfVariant[NoStickyNote] = + oneOfVariantFromMatchType( + NotFound, + plainBody[NoStickyNote] + .example( + Example.of( + summary = Some("No sticky note with id: 3 was found"), + value = NoStickyNote(StickyNoteId(3)) + ) + ) + ) + + val stickyNoteContentTooLongExample: EndpointOutput.OneOfVariant[StickyNoteContentTooLong] = + oneOfVariantFromMatchType( + BadRequest, + plainBody[StickyNoteContentTooLong] + .example( + Example.of( + summary = Some("Provided note content is too long (5004 characters). Max content length is 5000."), + value = StickyNoteContentTooLong(count = 5004, max = 5000) + ) + ) + ) + + val stickyNoteCountLimitReachedExample: EndpointOutput.OneOfVariant[StickyNoteCountLimitReached] = + oneOfVariantFromMatchType( + BadRequest, + plainBody[StickyNoteCountLimitReached] + .example( + Example.of( + summary = Some( + "Cannot add another sticky note, since max number of sticky notes was reached: 5 (see configuration)." + ), + value = StickyNoteCountLimitReached(max = 5) + ) + ) + ) + + } + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/Dtos.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/Dtos.scala new file mode 100644 index 00000000000..82e0e0e5d4a --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/Dtos.scala @@ -0,0 +1,119 @@ +package pl.touk.nussknacker.ui.api.description.stickynotes + +import derevo.circe.{decoder, encoder} +import derevo.derive +import io.circe.generic.JsonCodec +import io.circe.{Decoder, Encoder} +import pl.touk.nussknacker.engine.api.LayoutData +import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId} +import pl.touk.nussknacker.restmodel.BaseEndpointDefinitions +import pl.touk.nussknacker.ui.api.BaseHttpService.CustomAuthorizationError +import sttp.tapir.{Codec, CodecFormat, Schema} +import sttp.tapir.derevo.schema + +import java.time.Instant +import java.util.UUID + +object Dtos { + import pl.touk.nussknacker.ui.api.TapirCodecs.VersionIdCodec.{schema => versionIdSchema} + + final case class StickyNoteId(value: Long) extends AnyVal + + object StickyNoteId { + implicit val encoder: Encoder[StickyNoteId] = Encoder.encodeLong.contramap(_.value) + implicit val decoder: Decoder[StickyNoteId] = Decoder.decodeLong.map(StickyNoteId(_)) + } + + final case class StickyNoteCorrelationId(value: UUID) extends AnyVal + + object StickyNoteCorrelationId { + implicit val encoder: Encoder[StickyNoteCorrelationId] = Encoder.encodeUUID.contramap(_.value) + implicit val decoder: Decoder[StickyNoteCorrelationId] = Decoder.decodeUUID.map(StickyNoteCorrelationId(_)) + } + + implicit lazy val stickyNoteCorrelationIdSchema: Schema[StickyNoteCorrelationId] = + Schema.schemaForUUID.as[StickyNoteCorrelationId] + implicit lazy val stickyNoteIdSchema: Schema[StickyNoteId] = Schema.schemaForLong.as[StickyNoteId] + + @derive(encoder, decoder, schema) + case class Dimensions( + width: Long, + height: Long + ) + + @derive(encoder, decoder, schema) + case class StickyNote( + noteId: StickyNoteId, + content: String, + layoutData: LayoutData, + color: String, + dimensions: Dimensions, + targetEdge: Option[String], + editedBy: String, + editedAt: Instant + ) + + @derive(encoder, decoder, schema) + case class StickyNoteAddRequest( + scenarioVersionId: VersionId, + content: String, + layoutData: LayoutData, + color: String, + dimensions: Dimensions, + targetEdge: Option[String] + ) + + @derive(encoder, decoder, schema) + case class StickyNoteUpdateRequest( + noteId: StickyNoteId, + scenarioVersionId: VersionId, + content: String, + layoutData: LayoutData, + color: String, + dimensions: Dimensions, + targetEdge: Option[String] + ) + + @JsonCodec case class StickyNotesSettings( + maxContentLength: Int, + maxNotesCount: Int, + enabled: Boolean + ) + + object StickyNotesSettings { + val default: StickyNotesSettings = StickyNotesSettings(maxContentLength = 5000, maxNotesCount = 5, enabled = true) + } + + sealed trait StickyNotesError + + implicit lazy val layoutDataSchema: Schema[LayoutData] = Schema.derived + + object StickyNotesError { + + final case class NoScenario(scenarioName: ProcessName) extends StickyNotesError + final case object NoPermission extends StickyNotesError with CustomAuthorizationError + final case class StickyNoteContentTooLong(count: Int, max: Int) extends StickyNotesError + final case class StickyNoteCountLimitReached(max: Int) extends StickyNotesError + final case class NoStickyNote(noteId: StickyNoteId) extends StickyNotesError + + implicit val noScenarioCodec: Codec[String, NoScenario, CodecFormat.TextPlain] = + BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[NoScenario](e => s"No scenario ${e.scenarioName} found") + + implicit val noStickyNoteCodec: Codec[String, NoStickyNote, CodecFormat.TextPlain] = + BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[NoStickyNote](e => + s"No sticky note with id: ${e.noteId} was found" + ) + + implicit val stickyNoteContentTooLongCodec: Codec[String, StickyNoteContentTooLong, CodecFormat.TextPlain] = + BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[StickyNoteContentTooLong](e => + s"Provided note content is too long (${e.count} characters). Max content length is ${e.max}." + ) + + implicit val stickyNoteCountLimitReachedCodec: Codec[String, StickyNoteCountLimitReached, CodecFormat.TextPlain] = + BaseEndpointDefinitions.toTextPlainCodecSerializationOnly[StickyNoteCountLimitReached](e => + s"Cannot add another sticky note, since max number of sticky notes was reached: ${e.max} (see configuration)." + ) + + } + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/StickyNoteEvent.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/StickyNoteEvent.scala new file mode 100644 index 00000000000..64d62164c90 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/api/description/stickynotes/StickyNoteEvent.scala @@ -0,0 +1,14 @@ +package pl.touk.nussknacker.ui.api.description.stickynotes + +import io.circe.{Decoder, Encoder} + +object StickyNoteEvent extends Enumeration { + implicit val typeEncoder: Encoder[StickyNoteEvent.Value] = Encoder.encodeEnumeration(StickyNoteEvent) + implicit val typeDecoder: Decoder[StickyNoteEvent.Value] = Decoder.decodeEnumeration(StickyNoteEvent) + + type StickyNoteEvent = Value + val StickyNoteCreated: Value = Value("CREATED") + val StickyNoteUpdated: Value = Value("UPDATED") + val StickyNoteDeleted: Value = Value("DELETED") + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala index 1ee85981ab3..410a29037f1 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/config/FeatureTogglesConfig.scala @@ -7,6 +7,7 @@ import net.ceedubs.ficus.readers.ValueReader import pl.touk.nussknacker.engine.definition.component.Components.ComponentDefinitionExtractionMode import pl.touk.nussknacker.engine.util.config.FicusReaders import pl.touk.nussknacker.ui.api._ +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.StickyNotesSettings import pl.touk.nussknacker.ui.config.Implicits.parseOptionalConfig import pl.touk.nussknacker.ui.process.migrate.HttpRemoteEnvironmentConfig @@ -28,7 +29,8 @@ final case class FeatureTogglesConfig( testDataSettings: TestDataSettings, enableConfigEndpoint: Boolean, redirectAfterArchive: Boolean, - componentDefinitionExtractionMode: ComponentDefinitionExtractionMode + componentDefinitionExtractionMode: ComponentDefinitionExtractionMode, + stickyNotesSettings: StickyNotesSettings ) object FeatureTogglesConfig extends LazyLogging { @@ -56,8 +58,10 @@ object FeatureTogglesConfig extends LazyLogging { val tabs = parseOptionalConfig[List[TopTab]](config, "tabs") val intervalTimeSettings = config.as[IntervalTimeSettings]("intervalTimeSettings") val testDataSettings = config.as[TestDataSettings]("testDataSettings") - val redirectAfterArchive = config.getAs[Boolean]("redirectAfterArchive").getOrElse(true) - val componentDefinitionExtractionMode = parseComponentDefinitionExtractionMode(config) + val stickyNotesSettings = + config.getAs[StickyNotesSettings]("stickyNotesSettings").getOrElse(StickyNotesSettings.default) + val redirectAfterArchive = config.getAs[Boolean]("redirectAfterArchive").getOrElse(true) + val componentDefinitionExtractionMode = parseComponentDefinitionExtractionMode(config) FeatureTogglesConfig( development = isDevelopmentMode, @@ -76,6 +80,7 @@ object FeatureTogglesConfig extends LazyLogging { enableConfigEndpoint = enableConfigEndpoint, redirectAfterArchive = redirectAfterArchive, componentDefinitionExtractionMode = componentDefinitionExtractionMode, + stickyNotesSettings = stickyNotesSettings ) } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala index 034df3f48bc..a3e7344c378 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/NuTables.scala @@ -11,7 +11,8 @@ trait NuTables with ScenarioActivityEntityFactory with ScenarioLabelsEntityFactory with AttachmentEntityFactory - with DeploymentEntityFactory { + with DeploymentEntityFactory + with StickyNotesEntityFactory { protected val profile: JdbcProfile } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/StickyNotesEntityFactory.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/StickyNotesEntityFactory.scala new file mode 100644 index 00000000000..2bff4a1b639 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/StickyNotesEntityFactory.scala @@ -0,0 +1,112 @@ +package pl.touk.nussknacker.ui.db.entity + +import pl.touk.nussknacker.engine.api.LayoutData +import pl.touk.nussknacker.engine.api.process.{ProcessId, VersionId} +import pl.touk.nussknacker.ui.api.description.stickynotes.StickyNoteEvent +import pl.touk.nussknacker.ui.api.description.stickynotes.StickyNoteEvent.StickyNoteEvent +import slick.lifted.{ProvenShape, TableQuery => LTableQuery} +import slick.sql.SqlProfile.ColumnOption.NotNull +import io.circe.syntax._ +import io.circe._ +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{ + Dimensions, + StickyNote, + StickyNoteCorrelationId, + StickyNoteId +} + +import java.sql.Timestamp +import java.util.UUID + +trait StickyNotesEntityFactory extends BaseEntityFactory { + + import profile.api._ + + val processVersionsTable: LTableQuery[ProcessVersionEntityFactory#ProcessVersionEntity] + + class StickyNotesEntity(tag: Tag) extends Table[StickyNoteEventEntityData](tag, "sticky_notes") { + + def id = column[StickyNoteId]("id", O.PrimaryKey, O.AutoInc) + def noteCorrelationId = column[StickyNoteCorrelationId]("note_correlation_id", NotNull) + def content = column[String]("content", NotNull) + def layoutData = column[LayoutData]("layout_data", NotNull) + def color = column[String]("color", NotNull) + def dimensions = column[Dimensions]("dimensions", NotNull) + def targetEdge = column[Option[String]]("target_edge") + def eventCreator = column[String]("event_creator", NotNull) + def eventDate = column[Timestamp]("event_date", NotNull) + def eventType = column[StickyNoteEvent]("event_type", NotNull) + def scenarioId = column[ProcessId]("scenario_id", NotNull) + def scenarioVersionId = column[VersionId]("scenario_version_id", NotNull) + + def * : ProvenShape[StickyNoteEventEntityData] = ( + id, + noteCorrelationId, + content, + layoutData, + color, + dimensions, + targetEdge, + eventCreator, + eventDate, + eventType, + scenarioId, + scenarioVersionId + ) <> (StickyNoteEventEntityData.apply _ tupled, StickyNoteEventEntityData.unapply) + + def scenarioVersion = + foreignKey("sticky_notes_scenario_version_fk", (scenarioId, scenarioVersionId), processVersionsTable)( + t => (t.processId, t.id), + onUpdate = ForeignKeyAction.Cascade, + onDelete = ForeignKeyAction.Cascade + ) + + } + + implicit def stickyNoteEventColumnTyped: BaseColumnType[StickyNoteEvent] = + MappedColumnType.base[StickyNoteEvent, String](_.toString, StickyNoteEvent.withName) + + implicit def stickyNoteIdColumnTyped: BaseColumnType[StickyNoteId] = + MappedColumnType.base[StickyNoteId, Long](_.value, StickyNoteId(_)) + implicit def stickyNoteCorrelationIdColumnTyped: BaseColumnType[StickyNoteCorrelationId] = + MappedColumnType.base[StickyNoteCorrelationId, UUID](_.value, StickyNoteCorrelationId(_)) + + implicit def layoutDataColumnTyped: BaseColumnType[LayoutData] = MappedColumnType.base[LayoutData, String]( + _.asJson.noSpaces, + jsonStr => + parser.parse(jsonStr).flatMap(Decoder[LayoutData].decodeJson) match { + case Right(layoutData) => layoutData + case Left(error) => throw error + } + ) + + implicit def dimensionsColumnTyped: BaseColumnType[Dimensions] = MappedColumnType.base[Dimensions, String]( + _.asJson.noSpaces, + jsonStr => + parser.parse(jsonStr).flatMap(Decoder[Dimensions].decodeJson) match { + case Right(dimensions) => dimensions + case Left(error) => throw error + } + ) + + val stickyNotesTable: LTableQuery[StickyNotesEntityFactory#StickyNotesEntity] = LTableQuery(new StickyNotesEntity(_)) + +} + +final case class StickyNoteEventEntityData( + id: StickyNoteId, + noteCorrelationId: StickyNoteCorrelationId, + content: String, + layoutData: LayoutData, + color: String, + dimensions: Dimensions, + targetEdge: Option[String], + eventCreator: String, + eventDate: Timestamp, + eventType: StickyNoteEvent, + scenarioId: ProcessId, + scenarioVersionId: VersionId +) { + def toStickyNote: StickyNote = + StickyNote(id, content, layoutData, color, dimensions, targetEdge, eventCreator, eventDate.toInstant) +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/DbStickyNotesRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/DbStickyNotesRepository.scala new file mode 100644 index 00000000000..8acf546cf28 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/DbStickyNotesRepository.scala @@ -0,0 +1,174 @@ +package pl.touk.nussknacker.ui.process.repository.stickynotes + +import com.typesafe.scalalogging.LazyLogging +import db.util.DBIOActionInstances.DB +import pl.touk.nussknacker.engine.api.LayoutData +import pl.touk.nussknacker.engine.api.process.{ProcessId, VersionId} +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{ + Dimensions, + StickyNote, + StickyNoteCorrelationId, + StickyNoteId +} +import pl.touk.nussknacker.ui.api.description.stickynotes.StickyNoteEvent +import pl.touk.nussknacker.ui.db.entity.StickyNoteEventEntityData +import pl.touk.nussknacker.ui.db.{DbRef, NuTables} +import pl.touk.nussknacker.ui.process.repository.DbioRepository +import pl.touk.nussknacker.ui.security.api.LoggedUser +import slick.dbio.DBIOAction + +import java.sql.Timestamp +import java.time.Clock +import java.util.UUID +import scala.concurrent.ExecutionContext + +class DbStickyNotesRepository private (override protected val dbRef: DbRef, override val clock: Clock)( + implicit executionContext: ExecutionContext +) extends DbioRepository + with NuTables + with StickyNotesRepository + with LazyLogging { + + import profile.api._ + + override def findStickyNotes(scenarioId: ProcessId, scenarioVersionId: VersionId): DB[Seq[StickyNote]] = { + run( + stickyNotesTable + .filter(event => event.scenarioId === scenarioId && event.scenarioVersionId <= scenarioVersionId) + .groupBy(_.noteCorrelationId) + .map { case (noteCorrelationId, notes) => (noteCorrelationId, notes.map(_.eventDate).max) } + .join(stickyNotesTable) + .on { case ((noteCorrelationId, eventDate), event) => + event.noteCorrelationId === noteCorrelationId && event.eventDate === eventDate + } + .map { case ((_, _), event) => event } + .result + .map(events => events.filter(_.eventType != StickyNoteEvent.StickyNoteDeleted).map(_.toStickyNote)) + ) + } + + override def countStickyNotes(scenarioId: ProcessId, scenarioVersionId: VersionId): DB[Int] = { + run( + stickyNotesTable + .filter(event => event.scenarioId === scenarioId && event.scenarioVersionId <= scenarioVersionId) + .groupBy(_.noteCorrelationId) + .map { case (noteCorrelationId, notes) => (noteCorrelationId, notes.map(_.eventDate).max) } + .join(stickyNotesTable) + .on { case ((noteCorrelationId, eventDate), event) => + event.noteCorrelationId === noteCorrelationId && event.eventDate === eventDate + } + .map { case ((_, _), event) => event } + .result + .map(events => events.count(_.eventType != StickyNoteEvent.StickyNoteDeleted)) + ) + } + + override def findStickyNoteById( + noteId: StickyNoteId + )(implicit user: LoggedUser): DB[Option[StickyNoteEventEntityData]] = { + run( + stickyNotesTable + .filter(_.id === noteId) + .result + .headOption + ) + } + + override def addStickyNote( + content: String, + layoutData: LayoutData, + color: String, + dimensions: Dimensions, + targetEdge: Option[String], + scenarioId: ProcessId, + scenarioVersionId: VersionId + )( + implicit user: LoggedUser + ): DB[StickyNoteCorrelationId] = { + val now = Timestamp.from(clock.instant()) + val entity = StickyNoteEventEntityData( + id = StickyNoteId(0), // ignored since id is AutoInc + noteCorrelationId = StickyNoteCorrelationId(UUID.randomUUID()), + content = content, + layoutData = layoutData, + color = color, + dimensions = dimensions, + targetEdge = targetEdge, + eventDate = now, + eventCreator = user.id, + eventType = StickyNoteEvent.StickyNoteCreated, + scenarioId = scenarioId, + scenarioVersionId = scenarioVersionId + ) + run(stickyNotesTable += entity).flatMap { + case 0 => DBIOAction.failed(new IllegalStateException(s"This is odd, no sticky note was added")) + case 1 => DBIOAction.successful(entity.noteCorrelationId) + case n => + DBIOAction.failed( + new IllegalStateException(s"This is odd, more than one sticky note were added (added $n records).") + ) + } + + } + + private def updateStickyNote(id: StickyNoteId, updateAction: StickyNoteEventEntityData => StickyNoteEventEntityData)( + implicit user: LoggedUser + ): DB[Int] = { + run(for { + actionResult <- stickyNotesTable.filter(_.id === id).result.headOption.flatMap { + case None => + DBIOAction.failed( + new NoSuchElementException(s"Trying to update record (id=${id.value}) which is not present in the database") + ) + case Some(latestEvent) => + val newEvent = updateAction(latestEvent) + stickyNotesTable += newEvent + } + } yield actionResult) + } + + override def updateStickyNote( + noteId: StickyNoteId, + content: String, + layoutData: LayoutData, + color: String, + dimensions: Dimensions, + targetEdge: Option[String], + scenarioVersionId: VersionId, + )( + implicit user: LoggedUser + ): DB[Int] = { + val now = Timestamp.from(clock.instant()) + def updateAction(latestEvent: StickyNoteEventEntityData): StickyNoteEventEntityData = latestEvent.copy( + eventDate = now, + eventCreator = user.id, + eventType = StickyNoteEvent.StickyNoteUpdated, + content = content, + color = color, + dimensions = dimensions, + targetEdge = targetEdge, + layoutData = layoutData, + scenarioVersionId = scenarioVersionId + ) + updateStickyNote(noteId, updateAction) + } + + override def deleteStickyNote(noteId: StickyNoteId)(implicit user: LoggedUser): DB[Int] = { + val now = Timestamp.from(clock.instant()) + def updateAction(latestEvent: StickyNoteEventEntityData): StickyNoteEventEntityData = latestEvent.copy( + eventDate = now, + eventCreator = user.id, + eventType = StickyNoteEvent.StickyNoteDeleted + ) + updateStickyNote(noteId, updateAction) + } + +} + +object DbStickyNotesRepository { + + def create(dbRef: DbRef, clock: Clock)( + implicit executionContext: ExecutionContext, + ): StickyNotesRepository = new DbStickyNotesRepository(dbRef, clock) + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/StickyNotesRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/StickyNotesRepository.scala new file mode 100644 index 00000000000..6f762c7ad49 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/stickynotes/StickyNotesRepository.scala @@ -0,0 +1,55 @@ +package pl.touk.nussknacker.ui.process.repository.stickynotes + +import db.util.DBIOActionInstances.DB +import pl.touk.nussknacker.engine.api.LayoutData +import pl.touk.nussknacker.engine.api.process.{ProcessId, VersionId} +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{ + Dimensions, + StickyNote, + StickyNoteCorrelationId, + StickyNoteId +} +import pl.touk.nussknacker.ui.db.entity.StickyNoteEventEntityData +import pl.touk.nussknacker.ui.security.api.LoggedUser + +import java.time.Clock + +trait StickyNotesRepository { + + def clock: Clock + + def findStickyNotes( + scenarioId: ProcessId, + scenarioVersionId: VersionId + ): DB[Seq[StickyNote]] + + def countStickyNotes( + scenarioId: ProcessId, + scenarioVersionId: VersionId + ): DB[Int] + + def addStickyNote( + content: String, + layoutData: LayoutData, + color: String, + dimensions: Dimensions, + targetEdge: Option[String], + scenarioId: ProcessId, + scenarioVersionId: VersionId + )(implicit user: LoggedUser): DB[StickyNoteCorrelationId] + + def updateStickyNote( + noteId: StickyNoteId, + content: String, + layoutData: LayoutData, + color: String, + dimensions: Dimensions, + targetEdge: Option[String], + scenarioVersionId: VersionId, + )(implicit user: LoggedUser): DB[Int] + + def findStickyNoteById(noteId: StickyNoteId)(implicit user: LoggedUser): DB[Option[StickyNoteEventEntityData]] + + def deleteStickyNote(noteId: StickyNoteId)(implicit user: LoggedUser): DB[Int] + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala index 7e76f370fb0..ec0da71149b 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala @@ -71,6 +71,7 @@ import pl.touk.nussknacker.ui.process.processingtype.provider.ReloadableProcessi import pl.touk.nussknacker.ui.process.repository._ import pl.touk.nussknacker.ui.process.repository.activities.{DbScenarioActivityRepository, ScenarioActivityRepository} import pl.touk.nussknacker.ui.process.scenarioactivity.FetchScenarioActivityService +import pl.touk.nussknacker.ui.process.repository.stickynotes.DbStickyNotesRepository import pl.touk.nussknacker.ui.process.test.{PreliminaryScenarioTestDataSerDe, ScenarioTestService} import pl.touk.nussknacker.ui.process.version.{ScenarioGraphVersionRepository, ScenarioGraphVersionService} import pl.touk.nussknacker.ui.processreport.ProcessCounter @@ -172,6 +173,7 @@ class AkkaHttpBasedRouteProvider( implicit val implicitDbioRunner: DBIOActionRunner = dbioRunner val scenarioActivityRepository = DbScenarioActivityRepository.create(dbRef, designerClock) val actionRepository = DbScenarioActionRepository.create(dbRef, modelBuildInfo) + val stickyNotesRepository = DbStickyNotesRepository.create(dbRef, designerClock) val scenarioLabelsRepository = new ScenarioLabelsRepository(dbRef) val processRepository = DBFetchingProcessRepository.create(dbRef, actionRepository, scenarioLabelsRepository) // TODO: get rid of Future based repositories - it is easier to use everywhere one implementation - DBIOAction based which allows transactions handling @@ -409,6 +411,15 @@ class AkkaHttpBasedRouteProvider( scenarioService = processService, ) + val stickyNotesApiHttpService = new StickyNotesApiHttpService( + authManager = authManager, + stickyNotesRepository = stickyNotesRepository, + scenarioService = processService, + scenarioAuthorizer = processAuthorizer, + dbioRunner, + stickyNotesSettings = featureTogglesConfig.stickyNotesSettings + ) + val scenarioActivityApiHttpService = new ScenarioActivityApiHttpService( authManager = authManager, fetchScenarioActivityService = fetchScenarioActivityService, @@ -601,6 +612,7 @@ class AkkaHttpBasedRouteProvider( scenarioActivityApiHttpService, scenarioLabelsApiHttpService, scenarioParametersHttpService, + stickyNotesApiHttpService, userApiHttpService, statisticsApiHttpService ) diff --git a/designer/server/src/test/resources/config/common-designer.conf b/designer/server/src/test/resources/config/common-designer.conf index 508a1300a82..ce04adefb40 100644 --- a/designer/server/src/test/resources/config/common-designer.conf +++ b/designer/server/src/test/resources/config/common-designer.conf @@ -54,6 +54,12 @@ testDataSettings: { resultsMaxBytes: 50000000 } +stickyNotesSettings: { + maxContentLength: 5000, + maxNotesCount: 5, + enabled: true +} + scenarioLabelSettings: { validationRules = [ { diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/WithSimplifiedConfigScenarioHelper.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/WithSimplifiedConfigScenarioHelper.scala index 93beb1f8d1e..dd3f02b9294 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/WithSimplifiedConfigScenarioHelper.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/WithSimplifiedConfigScenarioHelper.scala @@ -6,6 +6,8 @@ import pl.touk.nussknacker.test.base.db.WithTestDb import pl.touk.nussknacker.test.config.WithSimplifiedDesignerConfig import pl.touk.nussknacker.test.config.WithSimplifiedDesignerConfig.TestCategory import pl.touk.nussknacker.test.utils.domain.ScenarioHelper +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{StickyNoteAddRequest, StickyNoteCorrelationId} +import pl.touk.nussknacker.ui.process.repository.ProcessRepository.ProcessUpdated import scala.concurrent.ExecutionContext.Implicits.global @@ -39,4 +41,12 @@ trait WithSimplifiedConfigScenarioHelper { rawScenarioHelper.createSavedScenario(scenario, usedCategory.stringify, isFragment = true) } + def updateScenario(scenarioName: ProcessName, newVersion: CanonicalProcess): ProcessUpdated = { + rawScenarioHelper.updateScenario(scenarioName, newVersion) + } + + def addStickyNote(scenarioName: ProcessName, request: StickyNoteAddRequest): StickyNoteCorrelationId = { + rawScenarioHelper.addStickyNote(scenarioName, request) + } + } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala index 7f893afe583..91f3ed111fd 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/utils/domain/ScenarioHelper.scala @@ -10,14 +10,20 @@ import pl.touk.nussknacker.engine.management.FlinkStreamingPropertiesConfig import pl.touk.nussknacker.test.PatientScalaFutures import pl.touk.nussknacker.test.config.WithSimplifiedDesignerConfig.TestProcessingType.Streaming import pl.touk.nussknacker.test.mock.TestAdditionalUIConfigProvider +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{StickyNoteAddRequest, StickyNoteCorrelationId} import pl.touk.nussknacker.ui.db.DbRef import pl.touk.nussknacker.ui.definition.ScenarioPropertiesConfigFinalizer import pl.touk.nussknacker.ui.process.NewProcessPreparer import pl.touk.nussknacker.ui.process.processingtype.ValueWithRestriction import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider -import pl.touk.nussknacker.ui.process.repository.ProcessRepository.CreateProcessAction +import pl.touk.nussknacker.ui.process.repository.ProcessRepository.{ + CreateProcessAction, + ProcessUpdated, + UpdateProcessAction +} import pl.touk.nussknacker.ui.process.repository._ import pl.touk.nussknacker.ui.process.repository.activities.DbScenarioActivityRepository +import pl.touk.nussknacker.ui.process.repository.stickynotes.{DbStickyNotesRepository, StickyNotesRepository} import pl.touk.nussknacker.ui.security.api.{LoggedUser, RealLoggedUser} import slick.dbio.DBIOAction @@ -39,6 +45,7 @@ private[test] class ScenarioHelper(dbRef: DbRef, clock: Clock, designerConfig: C ) private val scenarioLabelsRepository: ScenarioLabelsRepository = new ScenarioLabelsRepository(dbRef) + private val stickyNotesRepository: StickyNotesRepository = DbStickyNotesRepository.create(dbRef, clock) private val writeScenarioRepository: DBProcessRepository = new DBProcessRepository( dbRef, @@ -75,6 +82,20 @@ private[test] class ScenarioHelper(dbRef: DbRef, clock: Clock, designerConfig: C saveAndGetId(scenario, category, isFragment).futureValue } + def updateScenario( + scenarioName: ProcessName, + newScenario: CanonicalProcess + ): ProcessUpdated = { + updateAndGetScenarioVersions(scenarioName, newScenario).futureValue + } + + def addStickyNote( + scenarioName: ProcessName, + request: StickyNoteAddRequest + ): StickyNoteCorrelationId = { + addStickyNoteForScenario(scenarioName, request).futureValue + } + def createDeployedExampleScenario(scenarioName: ProcessName, category: String, isFragment: Boolean): ProcessId = { (for { id <- prepareValidScenario(scenarioName, category, isFragment) @@ -189,6 +210,44 @@ private[test] class ScenarioHelper(dbRef: DbRef, clock: Clock, designerConfig: C } yield id } + private def updateAndGetScenarioVersions( + scenarioName: ProcessName, + newScenario: CanonicalProcess + ): Future[ProcessUpdated] = { + for { + scenarioId <- futureFetchingScenarioRepository.fetchProcessId(scenarioName).map(_.get) + action = UpdateProcessAction( + scenarioId, + newScenario, + comment = None, + labels = List.empty, + increaseVersionWhenJsonNotChanged = true, + forwardedUserName = None + ) + processUpdated <- dbioRunner.runInTransaction(writeScenarioRepository.updateProcess(action)) + } yield processUpdated + } + + private def addStickyNoteForScenario( + scenarioName: ProcessName, + request: StickyNoteAddRequest + ): Future[StickyNoteCorrelationId] = { + for { + scenarioId <- futureFetchingScenarioRepository.fetchProcessId(scenarioName).map(_.get) + noteCorrelationId <- dbioRunner.runInTransaction( + stickyNotesRepository.addStickyNote( + request.content, + request.layoutData, + request.color, + request.dimensions, + request.targetEdge, + scenarioId, + request.scenarioVersionId + ) + ) + } yield noteCorrelationId + } + private def mapProcessingTypeDataProvider[T](value: T) = { ProcessingTypeDataProvider.withEmptyCombinedData( processingTypeWithCategories.map { case (processingType, _) => diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpServiceBusinessSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpServiceBusinessSpec.scala new file mode 100644 index 00000000000..fcaeffd1ae3 --- /dev/null +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/StickyNotesApiHttpServiceBusinessSpec.scala @@ -0,0 +1,165 @@ +package pl.touk.nussknacker.ui.api + +import io.restassured.RestAssured.`given` +import io.restassured.module.scala.RestAssuredSupport.AddThenToResponse +import org.scalatest.freespec.AnyFreeSpecLike +import pl.touk.nussknacker.engine.api.LayoutData +import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId} +import pl.touk.nussknacker.engine.build.ScenarioBuilder +import pl.touk.nussknacker.test.base.it.{NuItTest, WithSimplifiedConfigScenarioHelper} +import pl.touk.nussknacker.test.config.{ + WithBusinessCaseRestAssuredUsersExtensions, + WithMockableDeploymentManager, + WithSimplifiedDesignerConfig +} +import pl.touk.nussknacker.test.{ + NuRestAssureExtensions, + NuRestAssureMatchers, + RestAssuredVerboseLoggingIfValidationFails +} +import pl.touk.nussknacker.ui.api.description.stickynotes.Dtos.{Dimensions, StickyNoteAddRequest} + +import java.util.UUID + +class StickyNotesApiHttpServiceBusinessSpec + extends AnyFreeSpecLike + with NuItTest + with WithSimplifiedDesignerConfig + with WithSimplifiedConfigScenarioHelper + with WithMockableDeploymentManager + with WithBusinessCaseRestAssuredUsersExtensions + with NuRestAssureExtensions + with NuRestAssureMatchers + with RestAssuredVerboseLoggingIfValidationFails { + + private val exampleScenarioName = UUID.randomUUID().toString + + private val exampleScenario = ScenarioBuilder + .requestResponse(exampleScenarioName) + .source("sourceId", "barSource") + .emptySink("sinkId", "barSink") + + private def stickyNoteToAdd(versionId: VersionId, content: String): StickyNoteAddRequest = + StickyNoteAddRequest(versionId, content, LayoutData(0, 1), "#aabbcc", Dimensions(300, 200), None) + + "The GET stickyNotes for scenario" - { + "return no notes if nothing was created" in { + given() + .applicationState { + createSavedScenario(exampleScenario) + } + .when() + .basicAuthAllPermUser() + .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=0") + .Then() + .statusCode(200) + .equalsJsonBody("[]") + } + + "return 404 if no scenario with given name exists" in { + given() + .when() + .basicAuthAllPermUser() + .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=0") + .Then() + .statusCode(404) + .equalsPlainBody(s"No scenario $exampleScenarioName found") + } + + "return zero notes for scenarioVersion=1 if notes were added in scenarioVersion=2" in { + given() + .applicationState { + createSavedScenario(exampleScenario) + val updatedProcess = updateScenario(ProcessName(exampleScenarioName), exampleScenario) + addStickyNote(ProcessName(exampleScenarioName), stickyNoteToAdd(updatedProcess.newVersion.get, "")) + } + .when() + .basicAuthAllPermUser() + .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=1") + .Then() + .statusCode(200) + .equalsJsonBody("[]") + } + + "return sticky notes for scenarioVersion=2" in { + given() + .applicationState { + createSavedScenario(exampleScenario) + val updatedProcess = updateScenario(ProcessName(exampleScenarioName), exampleScenario) + addStickyNote(ProcessName(exampleScenarioName), stickyNoteToAdd(updatedProcess.newVersion.get, "title1")) + } + .when() + .basicAuthAllPermUser() + .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=2") + .Then() + .statusCode(200) + .body( + matchJsonWithRegexValues( + s"""[ + { + "noteId": "${regexes.digitsRegex}", + "content": "title1", + "layoutData": { + "x": 0, + "y": 1 + }, + "color": "#aabbcc", + "dimensions": { + "width": 300, + "height": 200 + }, + "targetEdge": null, + "editedBy": "admin", + "editedAt": "${regexes.zuluDateRegex}" + } + ]""".stripMargin + ) + ) + + } + + "return sticky notes for scenarioVersion=2 even if more for scenarioVersion=3 were added" in { + given() + .applicationState { + createSavedScenario(exampleScenario) + val updatedProcess = updateScenario(ProcessName(exampleScenarioName), exampleScenario) + addStickyNote(ProcessName(exampleScenarioName), stickyNoteToAdd(updatedProcess.newVersion.get, "sticky 1")) + val updatedProcessOnceMore = updateScenario(ProcessName(exampleScenarioName), exampleScenario) + addStickyNote( + ProcessName(exampleScenarioName), + stickyNoteToAdd(updatedProcessOnceMore.newVersion.get, "sticky 2") + ) + } + .when() + .basicAuthAllPermUser() + .get(s"$nuDesignerHttpAddress/api/processes/$exampleScenarioName/stickyNotes?scenarioVersionId=2") + .Then() + .statusCode(200) + .body( + matchJsonWithRegexValues( + s"""[ + { + "noteId": "${regexes.digitsRegex}", + "content": "sticky 1", + "layoutData": { + "x": 0, + "y": 1 + }, + "color": "#aabbcc", + "dimensions": { + "width": 300, + "height": 200 + }, + "targetEdge": null, + "editedBy": "admin", + "editedAt": "${regexes.zuluDateRegex}" + } + ]""".stripMargin + ) + ) + + } + + } + +} diff --git a/docs-internal/api/nu-designer-openapi.yaml b/docs-internal/api/nu-designer-openapi.yaml index b20a4e94ff9..849a6c93f88 100644 --- a/docs-internal/api/nu-designer-openapi.yaml +++ b/docs-internal/api/nu-designer-openapi.yaml @@ -2452,6 +2452,406 @@ paths: security: - {} - httpAuth: [] + /api/processes/{scenarioName}/stickyNotes: + get: + tags: + - StickyNotes + summary: Returns sticky nodes for given scenario with version + operationId: getApiProcessesScenarionameStickynotes + parameters: + - name: Nu-Impersonate-User-Identity + in: header + required: false + schema: + type: + - string + - 'null' + - name: scenarioName + in: path + required: true + schema: + type: string + - name: scenarioVersionId + in: query + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/StickyNote' + examples: + Example: + summary: List of valid sticky notes returned for scenario + value: + - noteId: 1 + content: "##Title \nNote1" + layoutData: + x: 20 + y: 30 + color: '#99aa20' + dimensions: + width: 300 + height: 200 + editedBy: Marco + editedAt: '1970-01-21T00:35:36.602Z' + - noteId: 2 + content: "##Title \nNote1" + layoutData: + x: 20 + y: 30 + color: '#99aa20' + dimensions: + width: 300 + height: 200 + editedBy: Marco + editedAt: '1970-01-21T00:35:36.602Z' + '400': + description: 'Invalid value for: header Nu-Impersonate-User-Identity, Invalid + value for: query parameter scenarioVersionId' + content: + text/plain: + schema: + type: string + '401': + description: '' + content: + text/plain: + schema: + type: string + examples: + CannotAuthenticateUser: + value: The supplied authentication is invalid + ImpersonatedUserNotExistsError: + value: No impersonated user data found for provided identity + '403': + description: '' + content: + text/plain: + schema: + type: string + examples: + InsufficientPermission: + value: The supplied authentication is not authorized to access this + resource + ImpersonationMissingPermission: + value: The supplied authentication is not authorized to impersonate + '404': + description: '' + content: + text/plain: + schema: + type: string + examples: + Example: + summary: No scenario {scenarioName} found + value: No scenario s1 found + '501': + description: Impersonation is not supported for defined authentication mechanism + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Cannot authenticate impersonated user as impersonation + is not supported by the authentication mechanism + value: Provided authentication method does not support impersonation + security: + - {} + - httpAuth: [] + put: + tags: + - StickyNotes + summary: Updates sticky note with new values + operationId: putApiProcessesScenarionameStickynotes + parameters: + - name: Nu-Impersonate-User-Identity + in: header + required: false + schema: + type: + - string + - 'null' + - name: scenarioName + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/StickyNoteUpdateRequest' + example: + noteId: 1 + scenarioVersionId: 1 + content: '' + layoutData: + x: 12 + y: 33 + color: '#441022' + dimensions: + width: 300 + height: 200 + required: true + responses: + '200': + description: '' + '400': + description: '' + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Provided note content is too long (5004 characters). Max + content length is 5000. + value: Provided note content is too long (5004 characters). Max + content length is 5000. + '401': + description: '' + content: + text/plain: + schema: + type: string + examples: + CannotAuthenticateUser: + value: The supplied authentication is invalid + ImpersonatedUserNotExistsError: + value: No impersonated user data found for provided identity + '403': + description: '' + content: + text/plain: + schema: + type: string + examples: + InsufficientPermission: + value: The supplied authentication is not authorized to access this + resource + ImpersonationMissingPermission: + value: The supplied authentication is not authorized to impersonate + '404': + description: '' + content: + text/plain: + schema: + anyOf: + - type: string + - type: string + examples: + Example: + summary: 'No sticky note with id: 3 was found' + value: 'No sticky note with id: StickyNoteId(3) was found' + '501': + description: Impersonation is not supported for defined authentication mechanism + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Cannot authenticate impersonated user as impersonation + is not supported by the authentication mechanism + value: Provided authentication method does not support impersonation + security: + - {} + - httpAuth: [] + post: + tags: + - StickyNotes + summary: Creates new sticky note with given content + operationId: postApiProcessesScenarionameStickynotes + parameters: + - name: Nu-Impersonate-User-Identity + in: header + required: false + schema: + type: + - string + - 'null' + - name: scenarioName + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/StickyNoteAddRequest' + example: + scenarioVersionId: 1 + content: '' + layoutData: + x: 12 + y: 33 + color: '#441022' + dimensions: + width: 300 + height: 200 + required: true + responses: + '200': + description: '' + content: + application/json: + schema: + type: string + format: uuid + '400': + description: '' + content: + text/plain: + schema: + anyOf: + - type: string + - type: string + examples: + Example: + summary: 'Cannot add another sticky note, since max number of sticky + notes was reached: 5 (see configuration).' + value: 'Cannot add another sticky note, since max number of sticky + notes was reached: 5 (see configuration).' + '401': + description: '' + content: + text/plain: + schema: + type: string + examples: + CannotAuthenticateUser: + value: The supplied authentication is invalid + ImpersonatedUserNotExistsError: + value: No impersonated user data found for provided identity + '403': + description: '' + content: + text/plain: + schema: + type: string + examples: + InsufficientPermission: + value: The supplied authentication is not authorized to access this + resource + ImpersonationMissingPermission: + value: The supplied authentication is not authorized to impersonate + '404': + description: '' + content: + text/plain: + schema: + type: string + examples: + Example: + summary: No scenario {scenarioName} found + value: No scenario s1 found + '501': + description: Impersonation is not supported for defined authentication mechanism + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Cannot authenticate impersonated user as impersonation + is not supported by the authentication mechanism + value: Provided authentication method does not support impersonation + security: + - {} + - httpAuth: [] + /api/processes/{scenarioName}/stickyNotes/{noteId}: + delete: + tags: + - StickyNotes + summary: Deletes stickyNote by given noteId + operationId: deleteApiProcessesScenarionameStickynotesNoteid + parameters: + - name: Nu-Impersonate-User-Identity + in: header + required: false + schema: + type: + - string + - 'null' + - name: scenarioName + in: path + required: true + schema: + type: string + - name: noteId + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: '' + '400': + description: 'Invalid value for: header Nu-Impersonate-User-Identity, Invalid + value for: path parameter noteId' + content: + text/plain: + schema: + type: string + '401': + description: '' + content: + text/plain: + schema: + type: string + examples: + CannotAuthenticateUser: + value: The supplied authentication is invalid + ImpersonatedUserNotExistsError: + value: No impersonated user data found for provided identity + '403': + description: '' + content: + text/plain: + schema: + type: string + examples: + InsufficientPermission: + value: The supplied authentication is not authorized to access this + resource + ImpersonationMissingPermission: + value: The supplied authentication is not authorized to impersonate + '404': + description: '' + content: + text/plain: + schema: + anyOf: + - type: string + - type: string + examples: + Example: + summary: No scenario {scenarioName} found + value: No scenario s1 found + '501': + description: Impersonation is not supported for defined authentication mechanism + content: + text/plain: + schema: + type: string + examples: + Example: + summary: Cannot authenticate impersonated user as impersonation + is not supported by the authentication mechanism + value: Provided authentication method does not support impersonation + security: + - {} + - httpAuth: [] /api/scenarioTesting/{scenarioName}/adhoc/validate: post: tags: @@ -4987,6 +5387,19 @@ components: properties: dictId: type: string + Dimensions: + title: Dimensions + type: object + required: + - width + - height + properties: + width: + type: integer + format: int64 + height: + type: integer + format: int64 DisplayableUser: title: DisplayableUser type: object @@ -6664,6 +7077,92 @@ components: type: string enum: - ERROR + StickyNote: + title: StickyNote + type: object + required: + - noteId + - content + - layoutData + - color + - dimensions + - editedBy + - editedAt + properties: + noteId: + type: integer + format: int64 + content: + type: string + layoutData: + $ref: '#/components/schemas/LayoutData' + color: + type: string + dimensions: + $ref: '#/components/schemas/Dimensions' + targetEdge: + type: + - string + - 'null' + editedBy: + type: string + editedAt: + type: string + format: date-time + StickyNoteAddRequest: + title: StickyNoteAddRequest + type: object + required: + - scenarioVersionId + - content + - layoutData + - color + - dimensions + properties: + scenarioVersionId: + type: integer + format: int64 + content: + type: string + layoutData: + $ref: '#/components/schemas/LayoutData' + color: + type: string + dimensions: + $ref: '#/components/schemas/Dimensions' + targetEdge: + type: + - string + - 'null' + StickyNoteUpdateRequest: + title: StickyNoteUpdateRequest + type: object + required: + - noteId + - scenarioVersionId + - content + - layoutData + - color + - dimensions + properties: + noteId: + type: integer + format: int64 + scenarioVersionId: + type: integer + format: int64 + content: + type: string + layoutData: + $ref: '#/components/schemas/LayoutData' + color: + type: string + dimensions: + $ref: '#/components/schemas/Dimensions' + targetEdge: + type: + - string + - 'null' StringParameterEditor: title: StringParameterEditor type: object diff --git a/docs/Changelog.md b/docs/Changelog.md index 67927213ae4..b4f42020bd6 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -10,6 +10,10 @@ ### 1.19.0 (Not released yet) +* [#7181](https://github.com/TouK/nussknacker/pull/7181) StickyNotes feature + * sticky notes are designed to store information inside scenario/fragment, they are separate from graph nodes and do not take part in scenario logic + * new API available under `processes/{scenarioName}/stickyNotes` + * configuration `stickyNotesSettings` allowing to hide/show stickyNotes, set sticky notes max content length or its max number on a graph * [#7145](https://github.com/TouK/nussknacker/pull/7145) Lift TypingResult information for dictionaries * [#7116](https://github.com/TouK/nussknacker/pull/7116) Improve missing Flink Kafka Source / Sink TypeInformation * [#7123](https://github.com/TouK/nussknacker/pull/7123) Fix deployments for scenarios with dict editors after model reload diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md index ac47ca0fbd2..fd2975f560e 100644 --- a/docs/MigrationGuide.md +++ b/docs/MigrationGuide.md @@ -4,6 +4,15 @@ To see the biggest differences please consult the [changelog](Changelog.md). ## In version 1.19.0 (Not released yet) + +### Configuration changes + +* [#7181](https://github.com/TouK/nussknacker/pull/7181) Added designer configuration: stickyNotesSettings + * maxContentLength - max length of a sticky notes content (characters) + * maxNotesCount - max count of sticky notes inside one scenario/fragment + * enabled - if set to false stickyNotes feature is disabled, stickyNotes cant be created, they are also not loaded to graph + + ### Other changes * [#7116](https://github.com/TouK/nussknacker/pull/7116) Improve missing Flink Kafka Source / Sink TypeInformation From 27073bb7e6ed17e3da32393fe8b1901aa9a7f1be Mon Sep 17 00:00:00 2001 From: Maciej Cichanowicz <30436981+Elmacioro@users.noreply.github.com> Date: Wed, 15 Jan 2025 09:28:51 +0100 Subject: [PATCH 05/10] Fix fragment nodes errors in scenario's tips (#7446) --- .../client/src/components/graph/Graph.tsx | 19 +++++++++++++++--- .../client/src/components/graph/NodeUtils.ts | 19 ++++++++++++++++++ .../tips/error/NodeErrorsLinkSection.tsx | 20 +++++++++++++++++++ docs/Changelog.md | 3 +++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/designer/client/src/components/graph/Graph.tsx b/designer/client/src/components/graph/Graph.tsx index f1f045dc0b7..bd4caa06bf4 100644 --- a/designer/client/src/components/graph/Graph.tsx +++ b/designer/client/src/components/graph/Graph.tsx @@ -740,7 +740,7 @@ export class Graph extends React.Component { }; }; - #highlightNodes = (selectedNodeIds: string[] = [], process = this.props.scenario): void => { + #highlightNodes = (selectedNodeIds: string[] = [], scenario = this.props.scenario): void => { this.processGraphPaper.freeze(); const elements = this.graph.getElements(); elements.forEach((cell) => { @@ -748,8 +748,10 @@ export class Graph extends React.Component { this.#unhighlightCell(cell, nodeFocused); }); - const validationErrors = ProcessUtils.getValidationErrors(process); - const invalidNodeIds = [...keys(validationErrors?.invalidNodes), ...validationErrors.globalErrors.flatMap((e) => e.nodeIds)]; + const validationErrors = ProcessUtils.getValidationErrors(scenario); + const invalidNodeKeys = [...keys(validationErrors?.invalidNodes)]; + const invalidFragmentNodes = this.#getInvalidFragmentNodes(invalidNodeKeys, scenario.scenarioGraph); + const invalidNodeIds = [...invalidNodeKeys, ...validationErrors.globalErrors.flatMap((e) => e.nodeIds), ...invalidFragmentNodes]; // fast indicator for loose nodes, faster than async validation elements.forEach((el) => { @@ -765,6 +767,17 @@ export class Graph extends React.Component { this.processGraphPaper.unfreeze(); }; + #getInvalidFragmentNodes = (invalidNodeIds: string[], scenarioGraph: ScenarioGraph): string[] => { + const invalidFragmentNodeIds: Set = new Set(); + invalidNodeIds.forEach((invalidNodeId) => { + if (NodeUtils.isFragmentNodeReference(invalidNodeId, scenarioGraph)) { + const fragmentId = scenarioGraph.nodes.find((n) => invalidNodeId.startsWith(n.id))?.id; + invalidFragmentNodeIds.add(fragmentId); + } + }); + return [...invalidFragmentNodeIds]; + }; + #highlightCell(cell: dia.Cell, className: string): void { this.processGraphPaper.findViewByModel(cell)?.highlight(null, { highlighter: { diff --git a/designer/client/src/components/graph/NodeUtils.ts b/designer/client/src/components/graph/NodeUtils.ts index 267eb2a31a4..ad075e7e9ca 100644 --- a/designer/client/src/components/graph/NodeUtils.ts +++ b/designer/client/src/components/graph/NodeUtils.ts @@ -213,6 +213,25 @@ class NodeUtils { } } }; + + // We can recognize that a node is part of a referenced fragment by making sure that: + // 1. The nodeId is not present in the scenarioGraph + // 2. There is a fragment node in the scenarioGraph whose name is part of the nodeId + isFragmentNodeReference = (nodeId: NodeId, scenarioGraph: ScenarioGraph): boolean => { + const isNodePresentInScenarioGraph = !!this.getNodeById(nodeId, scenarioGraph); + return !isNodePresentInScenarioGraph && scenarioGraph.nodes.some((n) => nodeId.startsWith(n.id)); + }; + + getDetailsFromFragmentNode = (nodeId: NodeId, scenarioGraph: ScenarioGraph): { fragmentId: string; fragmentNodeId: string } | null => { + if (this.isFragmentNodeReference(nodeId, scenarioGraph)) { + const fragment = scenarioGraph.nodes.find((n) => nodeId.startsWith(n.id)); + return { + fragmentId: fragment.ref.id, + fragmentNodeId: nodeId.replace(`${fragment.id}-`, ""), + }; + } + return null; + }; } //TODO this pattern is not necessary, just export every public function as in actions.js diff --git a/designer/client/src/components/tips/error/NodeErrorsLinkSection.tsx b/designer/client/src/components/tips/error/NodeErrorsLinkSection.tsx index f67db205140..e4e490fe643 100644 --- a/designer/client/src/components/tips/error/NodeErrorsLinkSection.tsx +++ b/designer/client/src/components/tips/error/NodeErrorsLinkSection.tsx @@ -5,6 +5,9 @@ import { NodeId, NodeType } from "../../../types"; import { ErrorHeader } from "./ErrorHeader"; import { NodeErrorLink } from "./NodeErrorLink"; import { Scenario } from "../../Process/types"; +import { NavLink } from "react-router-dom"; +import { ErrorLinkStyle } from "./styled"; +import { visualizationUrl } from "../../../common/VisualizationUrl"; interface NodeErrorsLinkSectionProps { nodeIds: NodeId[]; @@ -22,6 +25,23 @@ export default function NodeErrorsLinkSection(props: NodeErrorsLinkSectionProps)
{nodeIds.map((nodeId, index) => { + const isFragmentNodeReference = NodeUtils.isFragmentNodeReference(nodeId, scenario.scenarioGraph); + if (isFragmentNodeReference) { + const { fragmentId, fragmentNodeId } = NodeUtils.getDetailsFromFragmentNode(nodeId, scenario.scenarioGraph); + return ( + + + {nodeId} + + {index < nodeIds.length - 1 ? separator : null} + + ); + } const details = NodeUtils.getNodeById(nodeId, scenario.scenarioGraph); return ( diff --git a/docs/Changelog.md b/docs/Changelog.md index b4f42020bd6..ef7c6979fc8 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -52,6 +52,9 @@ * [#7404](https://github.com/TouK/nussknacker/pull/7404) Fix spel evaluation error when using conversion extensions methods or array.get extension method * [#7420](https://github.com/TouK/nussknacker/pull/7420) Add toInteger and toIntegerOrNull conversions. Also add canBeInteger extension * [#7438](https://github.com/TouK/nussknacker/pull/7438) Map int32 integer format in OpenAPI schema to the `Integer` type +* [#7446](https://github.com/TouK/nussknacker/pull/7446) Small changes regarding node errors in fragments used in scenarios: + * Fragment error node tips in scenarios are now clickable and open problematic node edit window in a new tab. + * Fragment nodes are now highlighted when they contain nodes with errors. ## 1.18 From c8ed7c6bb0760025e76c8f59290c38c6236d0865 Mon Sep 17 00:00:00 2001 From: Dawid Poliszak Date: Wed, 15 Jan 2025 15:21:21 +0100 Subject: [PATCH 06/10] skip fragment test --- designer/client/cypress/e2e/fragment.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/designer/client/cypress/e2e/fragment.cy.ts b/designer/client/cypress/e2e/fragment.cy.ts index 5a9cf09f0e7..a3629f2665a 100644 --- a/designer/client/cypress/e2e/fragment.cy.ts +++ b/designer/client/cypress/e2e/fragment.cy.ts @@ -1,4 +1,4 @@ -describe("Fragment", () => { +describe.skip("Fragment", () => { const seed = "fragment"; before(() => { cy.deleteAllTestProcesses({ From e0f92f753ea57ccdcc58bafca6ca9c0c6586a9d0 Mon Sep 17 00:00:00 2001 From: Piotr Rudnicki Date: Thu, 16 Jan 2025 12:57:52 +0100 Subject: [PATCH 07/10] [Nu-7393] Allow copying alert message when it is of an error type (#7464) Co-authored-by: Piotr Rudnicki --- designer/client/package-lock.json | 27 +++++++ designer/client/package.json | 1 + .../components/notifications/Notification.tsx | 14 +++- .../components/notifications/copyTooltip.tsx | 78 +++++++++++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 designer/client/src/components/notifications/copyTooltip.tsx diff --git a/designer/client/package-lock.json b/designer/client/package-lock.json index 97f763865a8..9054fa1411b 100644 --- a/designer/client/package-lock.json +++ b/designer/client/package-lock.json @@ -26,6 +26,7 @@ "@touk/window-manager": "1.9.1", "ace-builds": "1.34.2", "axios": "1.7.5", + "copy-to-clipboard": "3.3.1", "d3-transition": "3.0.1", "d3-zoom": "3.0.0", "dagre": "0.8.5", @@ -10357,6 +10358,14 @@ "node": ">=0.10.0" } }, + "node_modules/copy-to-clipboard": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz", + "integrity": "sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw==", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/copy-webpack-plugin": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", @@ -26188,6 +26197,11 @@ "node": ">= 0.10" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -36096,6 +36110,14 @@ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", "dev": true }, + "copy-to-clipboard": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz", + "integrity": "sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw==", + "requires": { + "toggle-selection": "^1.0.6" + } + }, "copy-webpack-plugin": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", @@ -47973,6 +47995,11 @@ "through2": "^2.0.3" } }, + "toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, "toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/designer/client/package.json b/designer/client/package.json index 0330944e1be..fb37fe878d4 100644 --- a/designer/client/package.json +++ b/designer/client/package.json @@ -19,6 +19,7 @@ "@touk/window-manager": "1.9.1", "ace-builds": "1.34.2", "axios": "1.7.5", + "copy-to-clipboard": "3.3.1", "d3-transition": "3.0.1", "d3-zoom": "3.0.0", "dagre": "0.8.5", diff --git a/designer/client/src/components/notifications/Notification.tsx b/designer/client/src/components/notifications/Notification.tsx index b9a4d9e5ce4..988536b5e1a 100644 --- a/designer/client/src/components/notifications/Notification.tsx +++ b/designer/client/src/components/notifications/Notification.tsx @@ -1,6 +1,8 @@ import React, { ReactElement } from "react"; import { Alert, AlertColor } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; +import { CopyTooltip } from "./copyTooltip"; +import { useTranslation } from "react-i18next"; interface Props { icon: ReactElement; @@ -10,9 +12,19 @@ interface Props { } export default function Notification({ icon, message, type }: Props): JSX.Element { - return ( + const { t } = useTranslation(); + + const alertContent = ( }> {message} ); + + return type === "error" ? ( + + {alertContent} + + ) : ( + alertContent + ); } diff --git a/designer/client/src/components/notifications/copyTooltip.tsx b/designer/client/src/components/notifications/copyTooltip.tsx new file mode 100644 index 00000000000..f06532ed38d --- /dev/null +++ b/designer/client/src/components/notifications/copyTooltip.tsx @@ -0,0 +1,78 @@ +import React, { PropsWithChildren, useEffect, useState } from "react"; +import copy from "copy-to-clipboard"; +import { Button, Tooltip } from "@mui/material"; +import { CopyAll, Done } from "@mui/icons-material"; + +export function useCopyClipboard(): [boolean, (value: string) => void] { + const [isCopied, setIsCopied] = useState(); + const [text, setText] = useState(); + + useEffect(() => { + if (isCopied) { + const id = setTimeout(() => { + setIsCopied(false); + }, 1000); + + return () => { + clearTimeout(id); + }; + } + }, [isCopied, text]); + + return [ + isCopied, + (value: string) => { + setText(value); + setIsCopied(copy(value)); + }, + ]; +} + +export function CopyTooltip({ + children, + text, + title, +}: PropsWithChildren<{ + text: string; + title: string; +}>): JSX.Element { + const [isCopied, copy] = useCopyClipboard(); + return ( + : } + onClick={(e) => { + copy(text); + e.stopPropagation(); + }} + > + {title} + + } + componentsProps={{ + popper: { + sx: { + opacity: 0.8, + }, + }, + tooltip: { + sx: { + bgcolor: (t) => (t.palette.mode === "dark" ? t.palette.common.white : t.palette.common.black), + color: (t) => (t.palette.mode === "dark" ? t.palette.common.black : t.palette.common.white), + }, + }, + arrow: { sx: { color: (t) => (t.palette.mode === "dark" ? t.palette.common.white : t.palette.common.black) } }, + }} + placement="bottom-start" + arrow + > + {children} + + ); +} From 9ed7326d0841f6b0d980c742d0693c7f6124a9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82abek?= Date: Thu, 16 Jan 2025 14:08:53 +0100 Subject: [PATCH 08/10] [NU-1974] Initialize model classloaders only once (#7447) initialize model classloaders only once --- .../ui/factory/NussknackerAppFactory.scala | 19 ++- .../ModelClassLoaderProvider.scala | 69 ++++++++++ .../LocalProcessingTypeDataLoader.scala | 9 +- .../loader/ProcessingTypeDataLoader.scala | 2 + ...sConfigBasedProcessingTypeDataLoader.scala | 18 ++- .../server/AkkaHttpBasedRouteProvider.scala | 12 +- .../test/base/it/NuResourcesTest.scala | 17 ++- .../test/mock/MockDeploymentManager.scala | 9 +- .../api/AppApiHttpServiceBusinessSpec.scala | 120 +++++++++++++----- .../ui/config/ConfigurationTest.scala | 13 +- .../ProcessingTypeDataProviderSpec.scala | 3 +- .../ScenarioParametersServiceTest.scala | 6 + ...amingDeploymentManagerProviderHelper.scala | 9 +- .../FlinkStreamingDeploymentManagerSpec.scala | 4 +- .../touk/nussknacker/engine/ModelData.scala | 7 +- 15 files changed, 255 insertions(+), 62 deletions(-) create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/ModelClassLoaderProvider.scala diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/factory/NussknackerAppFactory.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/factory/NussknackerAppFactory.scala index a340b48ba6c..44787d0477d 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/factory/NussknackerAppFactory.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/factory/NussknackerAppFactory.scala @@ -7,9 +7,10 @@ import cats.effect.{IO, Resource} import com.typesafe.scalalogging.LazyLogging import io.dropwizard.metrics5.MetricRegistry import io.dropwizard.metrics5.jmx.JmxReporter -import pl.touk.nussknacker.engine.ConfigWithUnresolvedVersion +import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap import pl.touk.nussknacker.engine.util.loader.ScalaServiceLoader import pl.touk.nussknacker.engine.util.{JavaClassVersionChecker, SLF4JBridgeHandlerRegistrar} +import pl.touk.nussknacker.engine.{ConfigWithUnresolvedVersion, ProcessingTypeConfig} import pl.touk.nussknacker.ui.config.{DesignerConfig, DesignerConfigLoader} import pl.touk.nussknacker.ui.configloader.{ProcessingTypeConfigsLoader, ProcessingTypeConfigsLoaderFactory} import pl.touk.nussknacker.ui.db.DbRef @@ -18,6 +19,7 @@ import pl.touk.nussknacker.ui.process.processingtype.loader.{ ProcessingTypeDataLoader, ProcessingTypesConfigBasedProcessingTypeDataLoader } +import pl.touk.nussknacker.ui.process.processingtype.{ModelClassLoaderDependencies, ModelClassLoaderProvider} import pl.touk.nussknacker.ui.server.{AkkaHttpBasedRouteProvider, NussknackerHttpServer} import pl.touk.nussknacker.ui.util.{ActorSystemBasedExecutionContextWithIORuntime, IOToFutureSttpBackendConverter} import sttp.client3.SttpBackend @@ -40,6 +42,9 @@ class NussknackerAppFactory( designerConfig, ioSttpBackend )(executionContextWithIORuntime.ioRuntime) + modelClassLoaderProvider = createModelClassLoaderProvider( + designerConfig.processingTypeConfigs.configByProcessingType + ) processingTypeDataLoader = createProcessingTypeDataLoader(processingTypeConfigsLoader) materializer = Materializer(system) _ <- Resource.eval(IO(JavaClassVersionChecker.check())) @@ -54,7 +59,8 @@ class NussknackerAppFactory( IOToFutureSttpBackendConverter.convert(ioSttpBackend)(executionContextWithIORuntime), processingTypeDataLoader, feStatisticsRepository, - clock + clock, + modelClassLoaderProvider )( system, materializer, @@ -116,6 +122,15 @@ class NussknackerAppFactory( ) } + private def createModelClassLoaderProvider( + processingTypeConfigs: Map[String, ProcessingTypeConfig] + ): ModelClassLoaderProvider = { + val defaultWorkingDirOpt = None + ModelClassLoaderProvider( + processingTypeConfigs.mapValuesNow(c => ModelClassLoaderDependencies(c.classPath, defaultWorkingDirOpt)) + ) + } + } object NussknackerAppFactory { diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/ModelClassLoaderProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/ModelClassLoaderProvider.scala new file mode 100644 index 00000000000..b3404eb0ac1 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/ModelClassLoaderProvider.scala @@ -0,0 +1,69 @@ +package pl.touk.nussknacker.ui.process.processingtype + +import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap +import pl.touk.nussknacker.engine.util.loader.ModelClassLoader + +import java.nio.file.Path + +final case class ModelClassLoaderDependencies(classpath: List[String], workingDirectoryOpt: Option[Path]) { + + def show(): String = { + val workingDirectoryReadable = workingDirectoryOpt match { + case Some(value) => value.toString + case None => "None (default)" + } + s"classpath: ${classpath.mkString(", ")}, workingDirectoryOpt: $workingDirectoryReadable" + } + +} + +class ModelClassLoaderProvider private ( + processingTypeClassLoaders: Map[String, (ModelClassLoader, ModelClassLoaderDependencies)] +) { + + def forProcessingTypeUnsafe(processingTypeName: String): ModelClassLoader = { + processingTypeClassLoaders + .getOrElse( + processingTypeName, + throw new IllegalArgumentException( + s"Unknown ProcessingType: $processingTypeName, known ProcessingTypes are: ${processingTypeName.mkString(", ")}" + ) + ) + ._1 + } + + def validateReloadConsistency( + dependenciesFromReload: Map[String, ModelClassLoaderDependencies] + ): Unit = { + if (dependenciesFromReload.keySet != processingTypeClassLoaders.keySet) { + throw new IllegalStateException( + s"""Processing types cannot be added, removed, or renamed during processing type reload. + |Reloaded processing types: [${dependenciesFromReload.keySet.toList.sorted.mkString(", ")}] + |Current processing types: [${processingTypeClassLoaders.keySet.toList.sorted.mkString(", ")}] + |If you need to modify this, please restart the application with desired config.""".stripMargin + ) + } + dependenciesFromReload.foreach { case (processingType, reloadedConfig) => + val currentConfig = processingTypeClassLoaders.mapValuesNow(_._2)(processingType) + if (reloadedConfig != currentConfig) { + throw new IllegalStateException( + s"Error during processing types reload. Model ClassLoader dependencies such as classpath cannot be modified during reload. " + + s"For processing type [$processingType], reloaded ClassLoader dependencies: [${reloadedConfig.show()}] " + + s"do not match current dependencies: [${currentConfig.show()}]" + ) + } + } + } + +} + +object ModelClassLoaderProvider { + + def apply(processingTypeConfig: Map[String, ModelClassLoaderDependencies]): ModelClassLoaderProvider = { + val processingTypesClassloaders = processingTypeConfig.map { case (name, deps) => + name -> (ModelClassLoader(deps.classpath, deps.workingDirectoryOpt) -> deps) + } + new ModelClassLoaderProvider(processingTypesClassloaders) + } + +} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/LocalProcessingTypeDataLoader.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/LocalProcessingTypeDataLoader.scala index 2fae13dc5c2..c5676f87cd3 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/LocalProcessingTypeDataLoader.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/LocalProcessingTypeDataLoader.scala @@ -7,7 +7,11 @@ import pl.touk.nussknacker.engine.api.process.ProcessingType import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap import pl.touk.nussknacker.ui.process.processingtype.loader.ProcessingTypeDataLoader.toValueWithRestriction import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataState -import pl.touk.nussknacker.ui.process.processingtype.{CombinedProcessingTypeData, ProcessingTypeData} +import pl.touk.nussknacker.ui.process.processingtype.{ + CombinedProcessingTypeData, + ModelClassLoaderProvider, + ProcessingTypeData +} class LocalProcessingTypeDataLoader( modelData: Map[ProcessingType, (String, ModelData)], @@ -16,7 +20,8 @@ class LocalProcessingTypeDataLoader( override def loadProcessingTypeData( getModelDependencies: ProcessingType => ModelDependencies, - getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies + getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies, + modelClassLoaderProvider: ModelClassLoaderProvider ): IO[ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData]] = IO { val processingTypes = modelData.map { case (processingType, (category, model)) => val deploymentManagerDependencies = getDeploymentManagerDependencies(processingType) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypeDataLoader.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypeDataLoader.scala index b2b526fc02f..28fa2bb20bc 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypeDataLoader.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypeDataLoader.scala @@ -6,6 +6,7 @@ import pl.touk.nussknacker.engine.{DeploymentManagerDependencies, ModelDependenc import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataState import pl.touk.nussknacker.ui.process.processingtype.{ CombinedProcessingTypeData, + ModelClassLoaderProvider, ProcessingTypeData, ValueWithRestriction } @@ -15,6 +16,7 @@ trait ProcessingTypeDataLoader { def loadProcessingTypeData( getModelDependencies: ProcessingType => ModelDependencies, getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies, + modelClassLoaderProvider: ModelClassLoaderProvider ): IO[ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData]] } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypesConfigBasedProcessingTypeDataLoader.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypesConfigBasedProcessingTypeDataLoader.scala index de70b5c0ba2..c1d31b5e5c2 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypesConfigBasedProcessingTypeDataLoader.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypesConfigBasedProcessingTypeDataLoader.scala @@ -18,16 +18,20 @@ class ProcessingTypesConfigBasedProcessingTypeDataLoader(processingTypeConfigsLo override def loadProcessingTypeData( getModelDependencies: ProcessingType => ModelDependencies, getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies, + modelClassLoaderProvider: ModelClassLoaderProvider ): IO[ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData]] = { processingTypeConfigsLoader .loadProcessingTypeConfigs() - .map(createProcessingTypeData(_, getModelDependencies, getDeploymentManagerDependencies)) + .map( + createProcessingTypeData(_, getModelDependencies, getDeploymentManagerDependencies, modelClassLoaderProvider) + ) } private def createProcessingTypeData( processingTypesConfig: ProcessingTypeConfigs, getModelDependencies: ProcessingType => ModelDependencies, - getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies + getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies, + modelClassLoaderProvider: ModelClassLoaderProvider ): ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData] = { // This step with splitting DeploymentManagerProvider loading for all processing types // and after that creating ProcessingTypeData is done because of the deduplication of deployments @@ -41,15 +45,23 @@ class ProcessingTypesConfigBasedProcessingTypeDataLoader(processingTypeConfigsLo ) (processingTypeConfig, provider, nameInputData) } + modelClassLoaderProvider.validateReloadConsistency(providerWithNameInputData.map { case (processingType, data) => + processingType -> ModelClassLoaderDependencies( + classpath = data._1.classPath, + workingDirectoryOpt = getModelDependencies(processingType).workingDirectoryOpt + ) + }) + val engineSetupNames = ScenarioParametersDeterminer.determineEngineSetupNames(providerWithNameInputData.mapValuesNow(_._3)) val processingTypesData = providerWithNameInputData .map { case (processingType, (processingTypeConfig, deploymentManagerProvider, _)) => logger.debug(s"Creating Processing Type: $processingType with config: $processingTypeConfig") val modelDependencies = getModelDependencies(processingType) + val modelClassLoader = modelClassLoaderProvider.forProcessingTypeUnsafe(processingType) val processingTypeData = ProcessingTypeData.createProcessingTypeData( processingType, - ModelData(processingTypeConfig, modelDependencies), + ModelData(processingTypeConfig, modelDependencies, modelClassLoader), deploymentManagerProvider, getDeploymentManagerDependencies(processingType), engineSetupNames(processingType), diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala index ec0da71149b..4faadc146f0 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala @@ -65,7 +65,7 @@ import pl.touk.nussknacker.ui.process.newdeployment.synchronize.{ DeploymentsStatusesSynchronizer } import pl.touk.nussknacker.ui.process.newdeployment.{DeploymentRepository, DeploymentService} -import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData +import pl.touk.nussknacker.ui.process.processingtype.{ModelClassLoaderProvider, ProcessingTypeData} import pl.touk.nussknacker.ui.process.processingtype.loader.ProcessingTypeDataLoader import pl.touk.nussknacker.ui.process.processingtype.provider.ReloadableProcessingTypeDataProvider import pl.touk.nussknacker.ui.process.repository._ @@ -109,7 +109,8 @@ class AkkaHttpBasedRouteProvider( sttpBackend: SttpBackend[Future, Any], processingTypeDataLoader: ProcessingTypeDataLoader, feStatisticsRepository: FEStatisticsRepository[Future], - designerClock: Clock + designerClock: Clock, + modelClassLoaderProvider: ModelClassLoaderProvider )( implicit system: ActorSystem, materializer: Materializer, @@ -140,7 +141,8 @@ class AkkaHttpBasedRouteProvider( dbioRunner, sttpBackend, featureTogglesConfig, - globalNotificationRepository + globalNotificationRepository, + modelClassLoaderProvider ) deploymentsStatusesSynchronizer = new DeploymentsStatusesSynchronizer( @@ -716,7 +718,8 @@ class AkkaHttpBasedRouteProvider( dbioActionRunner: DBIOActionRunner, sttpBackend: SttpBackend[Future, Any], featureTogglesConfig: FeatureTogglesConfig, - globalNotificationRepository: InMemoryTimeseriesRepository[Notification] + globalNotificationRepository: InMemoryTimeseriesRepository[Notification], + modelClassLoaderProvider: ModelClassLoaderProvider ): Resource[IO, ReloadableProcessingTypeDataProvider] = { Resource .make( @@ -735,6 +738,7 @@ class AkkaHttpBasedRouteProvider( sttpBackend, _ ), + modelClassLoaderProvider ) val loadAndNotifyIO = laodProcessingTypeDataIO .map { state => diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala index c2674f278f4..11600e5b9ed 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala @@ -124,10 +124,18 @@ trait NuResourcesTest protected val processingTypeConfig: ProcessingTypeConfig = ProcessingTypeConfig.read(ConfigWithScalaVersion.StreamingProcessTypeConfig) - protected val deploymentManagerProvider: DeploymentManagerProvider = - new MockManagerProvider(deploymentManager) + protected val deploymentManagerProvider: DeploymentManagerProvider = new MockManagerProvider(deploymentManager) - private val modelData = ModelData(processingTypeConfig, modelDependencies) + private val modelClassLoaderProvider = ModelClassLoaderProvider( + Map(Streaming.stringify -> ModelClassLoaderDependencies(processingTypeConfig.classPath, None)) + ) + + private val modelData = + ModelData( + processingTypeConfig, + modelDependencies, + modelClassLoaderProvider.forProcessingTypeUnsafe(Streaming.stringify) + ) protected val testProcessingTypeDataProvider: ProcessingTypeDataProvider[ProcessingTypeData, _] = mapProcessingTypeDataProvider( @@ -151,7 +159,8 @@ trait NuResourcesTest new ProcessingTypesConfigBasedProcessingTypeDataLoader(() => IO.pure(designerConfig.processingTypeConfigs)) .loadProcessingTypeData( _ => modelDependencies, - _ => deploymentManagerDependencies + _ => deploymentManagerDependencies, + modelClassLoaderProvider ) .unsafeRunSync() ) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockDeploymentManager.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockDeploymentManager.scala index e7ff26f3da9..4cf5180d00e 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockDeploymentManager.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockDeploymentManager.scala @@ -7,11 +7,6 @@ import cats.data.ValidatedNel import com.google.common.collect.LinkedHashMultimap import com.typesafe.config.Config import pl.touk.nussknacker.engine._ -import pl.touk.nussknacker.engine.api.definition.{ - NotBlankParameterValidator, - NotNullParameterValidator, - StringParameterEditor -} import pl.touk.nussknacker.engine.api.deployment._ import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.process.ProcessName @@ -19,6 +14,7 @@ import pl.touk.nussknacker.engine.api.{ProcessVersion, StreamMetaData} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.engine.deployment._ import pl.touk.nussknacker.engine.management.{FlinkDeploymentManager, FlinkStreamingDeploymentManagerProvider} +import pl.touk.nussknacker.engine.util.loader.ModelClassLoader import pl.touk.nussknacker.test.config.ConfigWithScalaVersion import pl.touk.nussknacker.test.utils.domain.TestFactory import shapeless.syntax.typeable.typeableOps @@ -47,7 +43,8 @@ class MockDeploymentManager( ) extends FlinkDeploymentManager( ModelData( ProcessingTypeConfig.read(ConfigWithScalaVersion.StreamingProcessTypeConfig), - TestFactory.modelDependencies + TestFactory.modelDependencies, + ModelClassLoader(ProcessingTypeConfig.read(ConfigWithScalaVersion.StreamingProcessTypeConfig).classPath, None) ), DeploymentManagerDependencies( deployedScenariosProvider, diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/AppApiHttpServiceBusinessSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/AppApiHttpServiceBusinessSpec.scala index 1f90931f0a6..d9f0245c76e 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/AppApiHttpServiceBusinessSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/AppApiHttpServiceBusinessSpec.scala @@ -2,6 +2,7 @@ package pl.touk.nussknacker.ui.api import com.typesafe.config.ConfigValueFactory.fromAnyRef import com.typesafe.config.{Config, ConfigFactory} +import com.typesafe.scalalogging.LazyLogging import io.restassured.RestAssured._ import io.restassured.module.scala.RestAssuredSupport.AddThenToResponse import org.hamcrest.Matchers._ @@ -29,9 +30,10 @@ class AppApiHttpServiceBusinessSpec with WithBusinessCaseRestAssuredUsersExtensions with NuRestAssureMatchers with RestAssuredVerboseLoggingIfValidationFails - with PatientScalaFutures { + with PatientScalaFutures + with LazyLogging { - private var simulateChangeInApplicationConfig: Boolean = false + private var simulatedChangeInApplicationConfig: Option[Config] = None "The app health check endpoint should" - { "return simple designer health check (with no scenario statuses check)" in { @@ -279,11 +281,11 @@ class AppApiHttpServiceBusinessSpec "The processing type data reload endpoint should" - { "reload processing types-related model data when" - { "'scenarioTypes' configuration is changed" in { - val componentNamesBeforeReload = fetchSortedComponentNames() + val componentNamesBeforeReload = fetchComponentGroupNamesWithOccurencesCount() given() .applicationState { - simulateChangeInApplicationConfig = true + simulatedChangeInApplicationConfig = Some(additionalProcessingTypeCustomization) } .when() .basicAuthAdmin() @@ -291,27 +293,86 @@ class AppApiHttpServiceBusinessSpec .Then() .statusCode(204) - val componentNamesAfterReload = fetchSortedComponentNames() + val componentNamesAfterReload = fetchComponentGroupNamesWithOccurencesCount() componentNamesAfterReload shouldNot be(componentNamesBeforeReload) - componentNamesAfterReload.length should be > (componentNamesBeforeReload.length) + componentNamesAfterReload("someComponentGroup") shouldBe 2 + } + } + "return error when" - { + "scenario type is added" in { + given() + .applicationState { + simulatedChangeInApplicationConfig = Some( + ConfigFactory.parseString( + s""" + |scenarioTypes { + | streaming2 { + | deploymentConfig { + | type: "development-tests" + | } + | modelConfig { + | classPath: [] + | } + | category: "Default" + | } + |} + |""".stripMargin + ) + ) + } + .when() + .basicAuthAdmin() + .post(s"$nuDesignerHttpAddress/api/app/processingtype/reload") + .Then() + .statusCode(500) + .body( + startsWith("Processing types cannot be added, removed, or renamed during processing type reload.") + ) + } + "classpath of a model is changed" in { + given() + .applicationState { + simulatedChangeInApplicationConfig = Some( + ConfigFactory.parseString( + s""" + |scenarioTypes { + | streaming { + | modelConfig { + | classPath: ["changed.jar"] + | } + | } + |} + |""".stripMargin + ) + ) + } + .when() + .basicAuthAdmin() + .post(s"$nuDesignerHttpAddress/api/app/processingtype/reload") + .Then() + .statusCode(500) + .body( + startsWith( + "Error during processing types reload. Model ClassLoader dependencies such as classpath cannot be modified during reload." + ) + ) } } } override def beforeEach(): Unit = { super.beforeEach() - if (simulateChangeInApplicationConfig) { - simulateChangeInApplicationConfig = false + if (simulatedChangeInApplicationConfig.isDefined) { + simulatedChangeInApplicationConfig = None forceReloadProcessingTypes() } } override def designerConfig: Config = { - if (simulateChangeInApplicationConfig) { - additionalProcessingTypeCustomization.withFallback(originDesignerConfig) - } else { - originDesignerConfig + simulatedChangeInApplicationConfig match { + case Some(customization) => customization.withFallback(originDesignerConfig) + case None => originDesignerConfig } } @@ -332,34 +393,33 @@ class AppApiHttpServiceBusinessSpec ConfigFactory.parseString( s""" |scenarioTypes { - | streaming3 { - | deploymentConfig { - | type: "mockable" - | id: "3" - | engineSetupName: "Mockable" - | } - | modelConfig: { - | classPath: [ - | "engine/flink/management/dev-model/target/scala-"$${scala.major.version}"/devModel.jar", - | "engine/flink/executor/target/scala-"$${scala.major.version}"/flinkExecutor.jar" - | ] - | } - | category: "Category1" - | } + | streaming { + | modelConfig { + | componentsUiConfig { + | sendCommunication { + | componentGroup: "someComponentGroup" + | } + | } + | } + | } |} |""".stripMargin ) } - private def fetchSortedComponentNames(): List[String] = { - given() + private def fetchComponentGroupNamesWithOccurencesCount(): Map[String, Int] = { + val body = given() .when() .basicAuthAdmin() .get(s"$nuDesignerHttpAddress/api/components") .Then() .statusCode(200) - .extractList("name") - .sorted + body + .extractList("componentGroupName") + .groupBy(identity) + .view + .map { case (name, occurences) => name -> occurences.length } + .toMap } private def forceReloadProcessingTypes(): Unit = { diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/config/ConfigurationTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/config/ConfigurationTest.scala index ac1e56a3885..afcb0dfe60d 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/config/ConfigurationTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/config/ConfigurationTest.scala @@ -3,6 +3,7 @@ package pl.touk.nussknacker.ui.config import cats.effect.unsafe.implicits.global import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import pl.touk.nussknacker.engine.util.loader.ModelClassLoader import pl.touk.nussknacker.engine.{ModelData, ProcessingTypeConfig} import pl.touk.nussknacker.test.config.ConfigWithScalaVersion import pl.touk.nussknacker.test.utils.domain.TestFactory @@ -17,10 +18,14 @@ class ConfigurationTest extends AnyFunSuite with Matchers { // warning: can't be val - uses ConfigFactory.load which breaks "should preserve config overrides" test private def globalConfig = ConfigWithScalaVersion.TestsConfig - private def modelData: ModelData = ModelData( - ProcessingTypeConfig.read(ConfigWithScalaVersion.StreamingProcessTypeConfig), - TestFactory.modelDependencies - ) + private def modelData: ModelData = { + val config = ProcessingTypeConfig.read(ConfigWithScalaVersion.StreamingProcessTypeConfig) + ModelData( + config, + TestFactory.modelDependencies, + ModelClassLoader(config.classPath, None) + ) + } private lazy val modelDataConfig = modelData.modelConfig diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeDataProviderSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeDataProviderSpec.scala index f4a8e88d82d..64aa228791e 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeDataProviderSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeDataProviderSpec.scala @@ -57,7 +57,8 @@ class ProcessingTypeDataProviderSpec extends AnyFunSuite with Matchers { loader .loadProcessingTypeData( _ => modelDependencies, - _ => TestFactory.deploymentManagerDependencies + _ => TestFactory.deploymentManagerDependencies, + ModelClassLoaderProvider(allProcessingTypes.map(_ -> ModelClassLoaderDependencies(List.empty, None)).toMap) ) .unsafeRunSync() } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ScenarioParametersServiceTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ScenarioParametersServiceTest.scala index fe079bdeec1..792c9141fa1 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ScenarioParametersServiceTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ScenarioParametersServiceTest.scala @@ -14,6 +14,7 @@ import pl.touk.nussknacker.engine.api.component.{ComponentProvider, DesignerWide import pl.touk.nussknacker.engine.api.process.ProcessingType import pl.touk.nussknacker.engine.definition.component.Components.ComponentDefinitionExtractionMode import pl.touk.nussknacker.engine.deployment.EngineSetupName +import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap import pl.touk.nussknacker.restmodel.scenariodetails.ScenarioParameters import pl.touk.nussknacker.security.Permission import pl.touk.nussknacker.test.ValidatedValuesDetailedMessage @@ -296,6 +297,11 @@ class ScenarioParametersServiceTest ComponentDefinitionExtractionMode.FinalDefinition ), _ => TestFactory.deploymentManagerDependencies, + ModelClassLoaderProvider( + designerConfig.processingTypeConfigs.configByProcessingType.mapValuesNow(conf => + ModelClassLoaderDependencies(conf.classPath, None) + ) + ) ) .unsafeRunSync() val parametersService = processingTypeData.getCombined().parametersService diff --git a/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerProviderHelper.scala b/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerProviderHelper.scala index 931b35d5794..aaa2604efe2 100644 --- a/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerProviderHelper.scala +++ b/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerProviderHelper.scala @@ -12,6 +12,7 @@ import pl.touk.nussknacker.engine.api.deployment.{ } import pl.touk.nussknacker.engine.definition.component.Components.ComponentDefinitionExtractionMode import pl.touk.nussknacker.engine.management.FlinkStreamingDeploymentManagerProvider +import pl.touk.nussknacker.engine.util.loader.ModelClassLoader import pl.touk.nussknacker.engine.{ ConfigWithUnresolvedVersion, DeploymentManagerDependencies, @@ -26,7 +27,8 @@ object FlinkStreamingDeploymentManagerProviderHelper { def createDeploymentManager( processingTypeConfig: ConfigWithUnresolvedVersion, ): DeploymentManager = { - val typeConfig = ProcessingTypeConfig.read(processingTypeConfig) + val typeConfig = ProcessingTypeConfig.read(processingTypeConfig) + val modelClassLoader = ModelClassLoader(typeConfig.classPath, None) val modelData = ModelData( processingTypeConfig = typeConfig, ModelDependencies( @@ -34,8 +36,9 @@ object FlinkStreamingDeploymentManagerProviderHelper { determineDesignerWideId = id => DesignerWideComponentId(id.toString), workingDirectoryOpt = None, _ => true, - ComponentDefinitionExtractionMode.FinalDefinition - ) + ComponentDefinitionExtractionMode.FinalDefinition, + ), + modelClassLoader ) val actorSystem = ActorSystem("FlinkStreamingDeploymentManagerProviderHelper") val backend = AsyncHttpClientFutureBackend.usingConfig(new DefaultAsyncHttpClientConfig.Builder().build()) diff --git a/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerSpec.scala b/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerSpec.scala index 5872b331cb7..0916e833329 100644 --- a/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerSpec.scala +++ b/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerSpec.scala @@ -18,6 +18,7 @@ import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} import pl.touk.nussknacker.engine.definition.component.Components.ComponentDefinitionExtractionMode import pl.touk.nussknacker.engine.deployment.DeploymentData +import pl.touk.nussknacker.engine.util.loader.ModelClassLoader import java.net.URI import java.nio.file.{Files, Paths} @@ -271,7 +272,8 @@ class FlinkStreamingDeploymentManagerSpec extends AnyFunSuite with Matchers with workingDirectoryOpt = None, _ => true, ComponentDefinitionExtractionMode.FinalDefinition - ) + ), + ModelClassLoader(processingTypeConfig.classPath, None) ) val definition = modelData.modelDefinition definition.components.components.map(_.id) should contain(ComponentId(ComponentType.Service, "accountService")) diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/ModelData.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/ModelData.scala index 43f00f94bd9..d87f651729f 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/ModelData.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/ModelData.scala @@ -40,8 +40,11 @@ object ModelData extends LazyLogging { Map[DesignerWideComponentId, ComponentAdditionalConfig] ) => ModelDefinition - def apply(processingTypeConfig: ProcessingTypeConfig, dependencies: ModelDependencies): ModelData = { - val modelClassLoader = ModelClassLoader(processingTypeConfig.classPath, dependencies.workingDirectoryOpt) + def apply( + processingTypeConfig: ProcessingTypeConfig, + dependencies: ModelDependencies, + modelClassLoader: ModelClassLoader + ): ModelData = { ClassLoaderModelData( _.resolveInputConfigDuringExecution(processingTypeConfig.modelConfig, modelClassLoader.classLoader), modelClassLoader, From fd82abe6bc38ed8f61d805fb0dd9e8e0ae1d67f2 Mon Sep 17 00:00:00 2001 From: mgoworko <37329559+mgoworko@users.noreply.github.com> Date: Thu, 16 Jan 2025 17:50:09 +0100 Subject: [PATCH 09/10] PeriodicDM as decorator for any DM with data stored in the main Nu DB (#7364) --- build.sbt | 48 +- designer/client/src/types/node.ts | 2 +- .../api/deployment/DeploymentManager.scala | 28 +- ...CachingProcessStateDeploymentManager.scala | 4 +- .../model/DeploymentWithRuntimeParams.scala | 12 + .../scheduler/model/ScheduleProperty.scala | 16 + .../model/ScheduledDeploymentDetails.scala | 28 + .../model/ScheduledProcessDetails.scala | 9 + .../AdditionalDeploymentDataProvider.scala | 9 + .../services}/ProcessConfigEnricher.scala | 26 +- .../SchedulePropertyExtractorFactory.scala | 13 + .../ScheduledExecutionPerformer.scala | 31 ++ .../services/ScheduledProcessListener.scala | 60 +++ .../testing/DeploymentManagerStub.scala | 2 + ...ingProcessStateDeploymentManagerSpec.scala | 9 +- .../common/V1_103__drop_buildinfo.sql | 0 .../migration/common/V1_105__add_indexes.sql | 0 ...etries_to_periodic_process_deployments.sql | 0 ...9__add_action_id_to_periodic_processes.sql | 0 .../V1_101__create_batch_periodic_tables.sql | 0 .../V1_102__add_program_args_and_jar_id.sql | 0 .../hsql/V1_104__multiple_schedules.sql | 0 .../hsql/V1_106__rename_model_config.sql | 0 .../hsql/V1_108__add_processing_type.sql | 0 .../V1_101__create_batch_periodic_tables.sql | 0 .../V1_102__add_program_args_and_jar_id.sql | 0 .../postgres/V1_104__multiple_schedules.sql | 0 .../postgres/V1_106__rename_model_config.sql | 0 .../postgres/V1_108__add_processing_type.sql | 0 .../web/static/assets/states/scheduled.svg | 0 .../static/assets/states/wait-reschedule.svg | 0 ...dicDeploymentManagerTablesDefinition.scala | 153 ++++++ ...061__PeriodicDeploymentManagerTables.scala | 8 + ...061__PeriodicDeploymentManagerTables.scala | 8 + .../PeriodicProcessDeploymentsTable.scala | 13 +- .../ui/db/entity/PeriodicProcessesTable.scala | 160 ++++++ ...aultAdditionalDeploymentDataProvider.scala | 18 + .../process}/periodic/DeploymentActor.scala | 26 +- .../periodic/PeriodicDeploymentManager.scala | 89 ++-- .../PeriodicDeploymentManagerDecorator.scala | 117 ++++ .../periodic/PeriodicProcessException.scala | 2 +- .../periodic/PeriodicProcessService.scala | 346 +++++++----- ...eriodicProcessStateDefinitionManager.scala | 13 +- .../periodic/PeriodicStateStatus.scala | 2 +- .../periodic/RescheduleFinishedActor.scala | 4 +- .../process/periodic/ScheduleProperty.scala | 4 +- .../process/periodic/SchedulingConfig.scala | 13 +- .../ui/process}/periodic/Utils.scala | 2 +- .../cron/CronParameterValidator.scala | 24 +- .../cron/CronSchedulePropertyExtractor.scala | 34 ++ .../legacy/db/LegacyDbInitializer.scala | 4 +- ...riodicProcessDeploymentsTableFactory.scala | 76 +++ .../LegacyPeriodicProcessesRepository.scala | 498 ++++++++++++++++++ .../LegacyPeriodicProcessesTableFactory.scala | 20 +- .../periodic/model/PeriodicProcess.scala | 18 + .../model/PeriodicProcessDeployment.scala | 71 +++ .../periodic/model/SchedulesState.scala | 46 +- .../utils}/DeterministicUUIDFromLong.scala | 2 +- .../SchedulePropertyExtractorUtils.scala | 27 +- .../InvalidDeploymentManagerStub.scala | 2 + .../processingtype/ProcessingTypeData.scala | 55 +- .../LocalProcessingTypeDataLoader.scala | 8 +- .../loader/ProcessingTypeDataLoader.scala | 6 +- ...sConfigBasedProcessingTypeDataLoader.scala | 31 +- .../DBFetchingProcessRepository.scala | 27 +- .../FetchingProcessRepository.scala | 11 +- .../PeriodicProcessesRepository.scala | 314 +++++------ .../repository/ScenarioActionRepository.scala | 68 ++- .../server/AkkaHttpBasedRouteProvider.scala | 5 +- ...tionsAndCommentsToScenarioActivities.scala | 1 - .../nussknacker/test/base/db/DbTesting.scala | 2 + .../test/base/it/NuResourcesTest.scala | 5 +- .../test/mock/MockDeploymentManager.scala | 3 +- .../mock/MockFetchingProcessRepository.scala | 19 +- .../DefaultComponentServiceSpec.scala | 2 + .../ProcessStateDefinitionServiceSpec.scala | 2 + ...eriodicProcessServiceIntegrationTest.scala | 264 +++++++--- .../PeriodicProcessesFetchingTest.scala | 42 +- .../CronSchedulePropertyExtractorTest.scala | 14 +- .../flink}/CronSchedulePropertyTest.scala | 6 +- .../periodic/flink}/DeploymentActorTest.scala | 27 +- .../flink}/DeploymentManagerStub.scala | 6 +- .../periodic/flink}/FlinkClientStub.scala | 2 +- .../PeriodicDeploymentManagerTest.scala | 35 +- .../flink}/PeriodicProcessDeploymentGen.scala | 14 +- .../periodic/flink/PeriodicProcessGen.scala | 39 ++ .../flink}/PeriodicProcessServiceTest.scala | 127 +++-- ...dicProcessStateDefinitionManagerTest.scala | 17 +- .../flink}/RescheduleFinishedActorTest.scala | 3 +- .../ScheduledExecutionPerformerStub.scala | 49 ++ .../ScheduledExecutionPerformerTest.scala | 53 +- .../process/periodic/flink}/UtilsSpec.scala | 5 +- .../db/InMemPeriodicProcessesRepository.scala | 297 ++++++++--- .../ProcessingTypeDataProviderSpec.scala | 4 +- .../ScenarioParametersServiceTest.scala | 4 +- docs/Changelog.md | 3 + docs/MigrationGuide.md | 34 ++ ...DevelopmentDeploymentManagerProvider.scala | 2 + .../MockableDeploymentManagerProvider.scala | 2 + .../aggregate/AggregatesSpec.scala | 126 +++-- .../transformer/aggregate/aggregates.scala | 11 +- .../aggregate/median/MedianHelper.scala | 8 +- engine/flink/management/periodic/README.md | 34 -- ...ssknacker.engine.DeploymentManagerProvider | 1 - ...ne.api.definition.CustomParameterValidator | 1 - ...inkPeriodicDeploymentManagerProvider.scala | 79 --- .../management/periodic/JarManager.scala | 23 - .../SchedulePropertyExtractorFactory.scala | 7 - .../periodic/flink/FlinkJarManager.scala | 118 ----- .../model/DeploymentWithJarData.scala | 25 - .../periodic/model/PeriodicProcess.scala | 21 - .../model/PeriodicProcessDeployment.scala | 52 -- .../AdditionalDeploymentDataProvider.scala | 27 - .../service/PeriodicProcessListener.scala | 61 --- .../management/periodic/JarManagerStub.scala | 38 -- .../periodic/PeriodicProcessGen.scala | 38 -- ...amingDeploymentManagerProviderHelper.scala | 6 +- .../engine/management/FlinkRestManager.scala | 29 +- .../FlinkScheduledExecutionPerformer.scala | 140 +++++ ...nkStreamingDeploymentManagerProvider.scala | 9 - .../management/FlinkRestManagerSpec.scala | 8 +- .../embedded/EmbeddedDeploymentManager.scala | 2 + ...esponseEmbeddedDeploymentManagerTest.scala | 13 +- .../k8s/manager/K8sDeploymentManager.scala | 1 + .../BaseK8sDeploymentManagerTest.scala | 2 +- .../K8sDeploymentManagerOnMocksTest.scala | 4 +- .../src/universal/conf/dev-application.conf | 13 +- 127 files changed, 3065 insertions(+), 1537 deletions(-) create mode 100644 designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/DeploymentWithRuntimeParams.scala create mode 100644 designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduleProperty.scala create mode 100644 designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduledDeploymentDetails.scala create mode 100644 designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduledProcessDetails.scala create mode 100644 designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/AdditionalDeploymentDataProvider.scala rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service => designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services}/ProcessConfigEnricher.scala (77%) create mode 100644 designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/SchedulePropertyExtractorFactory.scala create mode 100644 designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ScheduledExecutionPerformer.scala create mode 100644 designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ScheduledProcessListener.scala rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/common/V1_103__drop_buildinfo.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/common/V1_105__add_indexes.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/common/V1_107__add_retry_at_and_retries_to_periodic_process_deployments.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/common/V1_109__add_action_id_to_periodic_processes.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/hsql/V1_101__create_batch_periodic_tables.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/hsql/V1_102__add_program_args_and_jar_id.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/hsql/V1_104__multiple_schedules.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/hsql/V1_106__rename_model_config.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/hsql/V1_108__add_processing_type.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/postgres/V1_101__create_batch_periodic_tables.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/postgres/V1_102__add_program_args_and_jar_id.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/postgres/V1_104__multiple_schedules.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/postgres/V1_106__rename_model_config.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/db/batch_periodic/migration/postgres/V1_108__add_processing_type.sql (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/web/static/assets/states/scheduled.svg (100%) rename {engine/flink/management/periodic => designer/server}/src/main/resources/web/static/assets/states/wait-reschedule.svg (100%) create mode 100644 designer/server/src/main/scala/db/migration/V1_061__PeriodicDeploymentManagerTablesDefinition.scala create mode 100644 designer/server/src/main/scala/db/migration/hsql/V1_061__PeriodicDeploymentManagerTables.scala create mode 100644 designer/server/src/main/scala/db/migration/postgres/V1_061__PeriodicDeploymentManagerTables.scala rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db => designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity}/PeriodicProcessDeploymentsTable.scala (84%) create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/PeriodicProcessesTable.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/DefaultAdditionalDeploymentDataProvider.scala rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/DeploymentActor.scala (70%) rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/PeriodicDeploymentManager.scala (81%) create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicDeploymentManagerDecorator.scala rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/PeriodicProcessException.scala (74%) rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/PeriodicProcessService.scala (74%) rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/PeriodicProcessStateDefinitionManager.scala (80%) rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/PeriodicStateStatus.scala (98%) rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/RescheduleFinishedActor.scala (90%) rename engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SingleScheduleProperty.scala => designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/ScheduleProperty.scala (95%) rename engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicBatchConfig.scala => designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/SchedulingConfig.scala (71%) rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/Utils.scala (95%) rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/cron/CronParameterValidator.scala (63%) create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/cron/CronSchedulePropertyExtractor.scala rename engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/DbInitializer.scala => designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyDbInitializer.scala (95%) create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessDeploymentsTableFactory.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessesRepository.scala rename engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesTable.scala => designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessesTableFactory.scala (92%) create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/PeriodicProcess.scala create mode 100644 designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/PeriodicProcessDeployment.scala rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management => designer/server/src/main/scala/pl/touk/nussknacker/ui/process}/periodic/model/SchedulesState.scala (66%) rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/util => designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/utils}/DeterministicUUIDFromLong.scala (93%) rename engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SchedulePropertyExtractor.scala => designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/utils/SchedulePropertyExtractorUtils.scala (76%) rename {engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db => designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository}/PeriodicProcessesRepository.scala (65%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management => designer/server/src/test/scala/pl/touk/nussknacker/ui/process}/periodic/PeriodicProcessServiceIntegrationTest.scala (75%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management => designer/server/src/test/scala/pl/touk/nussknacker/ui/process}/periodic/PeriodicProcessesFetchingTest.scala (65%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/CronSchedulePropertyExtractorTest.scala (73%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/CronSchedulePropertyTest.scala (89%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/DeploymentActorTest.scala (63%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/DeploymentManagerStub.scala (93%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/FlinkClientStub.scala (96%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/PeriodicDeploymentManagerTest.scala (94%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/PeriodicProcessDeploymentGen.scala (53%) create mode 100644 designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessGen.scala rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/PeriodicProcessServiceTest.scala (81%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/PeriodicProcessStateDefinitionManagerTest.scala (86%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/RescheduleFinishedActorTest.scala (91%) create mode 100644 designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/ScheduledExecutionPerformerStub.scala rename engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/JarManagerTest.scala => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/ScheduledExecutionPerformerTest.scala (53%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/UtilsSpec.scala (94%) rename {engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic => designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink}/db/InMemPeriodicProcessesRepository.scala (53%) delete mode 100644 engine/flink/management/periodic/README.md delete mode 100644 engine/flink/management/periodic/src/main/resources/META-INF/services/pl.touk.nussknacker.engine.DeploymentManagerProvider delete mode 100644 engine/flink/management/periodic/src/main/resources/META-INF/services/pl.touk.nussknacker.engine.api.definition.CustomParameterValidator delete mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/FlinkPeriodicDeploymentManagerProvider.scala delete mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/JarManager.scala delete mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SchedulePropertyExtractorFactory.scala delete mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/flink/FlinkJarManager.scala delete mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/DeploymentWithJarData.scala delete mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/PeriodicProcess.scala delete mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/PeriodicProcessDeployment.scala delete mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/AdditionalDeploymentDataProvider.scala delete mode 100644 engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/PeriodicProcessListener.scala delete mode 100644 engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/JarManagerStub.scala delete mode 100644 engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessGen.scala create mode 100644 engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkScheduledExecutionPerformer.scala diff --git a/build.sbt b/build.sbt index 388e6e75faa..bcd09bc56a6 100644 --- a/build.sbt +++ b/build.sbt @@ -510,8 +510,7 @@ lazy val distribution: Project = sbt }, devArtifacts := { modelArtifacts.value ++ List( - (flinkDevModel / assembly).value -> "model/devModel.jar", - (flinkPeriodicDeploymentManager / assembly).value -> "managers/nussknacker-flink-periodic-manager.jar", + (flinkDevModel / assembly).value -> "model/devModel.jar", ) }, Universal / packageName := ("nussknacker" + "-" + version.value), @@ -611,8 +610,8 @@ lazy val flinkDeploymentManager = (project in flink("management")) libraryDependencies ++= { Seq( "org.typelevel" %% "cats-core" % catsV % Provided, - "org.apache.flink" % "flink-streaming-java" % flinkV % flinkScope - excludeAll ( + ("org.apache.flink" % "flink-streaming-java" % flinkV % flinkScope) + .excludeAll( ExclusionRule("log4j", "log4j"), ExclusionRule("org.slf4j", "slf4j-log4j12"), ExclusionRule("com.esotericsoftware", "kryo-shaded"), @@ -636,37 +635,6 @@ lazy val flinkDeploymentManager = (project in flink("management")) kafkaTestUtils % "it,test" ) -lazy val flinkPeriodicDeploymentManager = (project in flink("management/periodic")) - .settings(commonSettings) - .settings(assemblyNoScala("nussknacker-flink-periodic-manager.jar"): _*) - .settings(publishAssemblySettings: _*) - .settings( - name := "nussknacker-flink-periodic-manager", - libraryDependencies ++= { - Seq( - "org.typelevel" %% "cats-core" % catsV % Provided, - "com.typesafe.slick" %% "slick" % slickV % Provided, - "com.typesafe.slick" %% "slick-hikaricp" % slickV % "provided, test", - "com.github.tminglei" %% "slick-pg" % slickPgV, - "org.hsqldb" % "hsqldb" % hsqldbV % Test, - "org.flywaydb" % "flyway-core" % flywayV % Provided, - "com.cronutils" % "cron-utils" % cronParserV, - "com.typesafe.akka" %% "akka-actor" % akkaV, - "com.typesafe.akka" %% "akka-testkit" % akkaV % Test, - "com.dimafeng" %% "testcontainers-scala-scalatest" % testContainersScalaV % Test, - "com.dimafeng" %% "testcontainers-scala-postgresql" % testContainersScalaV % Test, - ) - } - ) - .dependsOn( - flinkDeploymentManager, - deploymentManagerApi % Provided, - scenarioCompiler % Provided, - componentsApi % Provided, - httpUtils % Provided, - testUtils % Test - ) - lazy val flinkMetricsDeferredReporter = (project in flink("metrics-deferred-reporter")) .settings(commonSettings) .settings( @@ -1811,10 +1779,10 @@ lazy val flinkBaseUnboundedComponents = (project in flink("components/base-unbou .settings( name := "nussknacker-flink-base-unbounded-components", libraryDependencies ++= Seq( - "org.apache.flink" % "flink-streaming-java" % flinkV % Provided, - "com.clearspring.analytics" % "stream" % "2.9.8" + "org.apache.flink" % "flink-streaming-java" % flinkV % Provided, // It is used only in QDigest which we don't use, while it's >20MB in size... - exclude ("it.unimi.dsi", "fastutil") + ("com.clearspring.analytics" % "stream" % "2.9.8") + .exclude("it.unimi.dsi", "fastutil") ) ) .dependsOn( @@ -2004,6 +1972,7 @@ lazy val designer = (project in file("designer/server")) "com.typesafe.akka" %% "akka-testkit" % akkaV % Test, "de.heikoseeberger" %% "akka-http-circe" % akkaHttpCirceV, "com.softwaremill.sttp.client3" %% "async-http-client-backend-cats" % sttpV, + "com.cronutils" % "cron-utils" % cronParserV, "ch.qos.logback" % "logback-core" % logbackV, "ch.qos.logback" % "logback-classic" % logbackV, "ch.qos.logback.contrib" % "logback-json-classic" % logbackJsonV, @@ -2026,6 +1995,7 @@ lazy val designer = (project in file("designer/server")) "com.beachape" %% "enumeratum-circe" % enumeratumV, "tf.tofu" %% "derevo-circe" % "0.13.0", "com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % openapiCirceYamlV, + "com.github.tminglei" %% "slick-pg" % slickPgV, "com.softwaremill.sttp.tapir" %% "tapir-akka-http-server" % tapirV, "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirV, "com.softwaremill.sttp.tapir" %% "tapir-derevo" % tapirV, @@ -2080,7 +2050,6 @@ lazy val designer = (project in file("designer/server")) liteEmbeddedDeploymentManager % Provided, liteK8sDeploymentManager % Provided, developmentTestsDeploymentManager % Provided, - flinkPeriodicDeploymentManager % Provided, schemedKafkaComponentsUtils % Provided, ) @@ -2170,7 +2139,6 @@ lazy val modules = List[ProjectReference]( requestResponseRuntime, liteEngineRuntimeApp, flinkDeploymentManager, - flinkPeriodicDeploymentManager, flinkDevModel, flinkDevModelJava, flinkTableApiComponents, diff --git a/designer/client/src/types/node.ts b/designer/client/src/types/node.ts index 014a4c13670..fe25fc99084 100644 --- a/designer/client/src/types/node.ts +++ b/designer/client/src/types/node.ts @@ -2,7 +2,7 @@ import { ProcessAdditionalFields, ReturnedType } from "./scenarioGraph"; import { FragmentInputParameter } from "../components/graph/node-modal/fragment-input-definition/item"; import { StickyNoteType } from "./stickyNote"; -type Type = "FragmentInput" | typeof StickyNoteType | string; +type Type = "FragmentInput" | typeof StickyNoteType | string; export type LayoutData = { x: number; y: number }; diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/DeploymentManager.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/DeploymentManager.scala index bade92ff999..8aa3ab96743 100644 --- a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/DeploymentManager.scala +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/DeploymentManager.scala @@ -1,9 +1,11 @@ package pl.touk.nussknacker.engine.api.deployment +import com.typesafe.config.Config import pl.touk.nussknacker.engine.api.deployment.inconsistency.InconsistentStateDetector +import pl.touk.nussknacker.engine.api.deployment.scheduler.services._ import pl.touk.nussknacker.engine.api.process.{ProcessIdWithName, ProcessName, VersionId} -import pl.touk.nussknacker.engine.newdeployment import pl.touk.nussknacker.engine.util.WithDataFreshnessStatusUtils.WithDataFreshnessStatusOps +import pl.touk.nussknacker.engine.{BaseModelData, DeploymentManagerDependencies, newdeployment} import java.time.Instant import scala.concurrent.ExecutionContext.Implicits._ @@ -48,6 +50,8 @@ trait DeploymentManager extends AutoCloseable { def stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport + def schedulingSupport: SchedulingSupport + def processCommand[Result](command: DMScenarioCommand[Result]): Future[Result] final def getProcessState( @@ -132,3 +136,25 @@ trait DeploymentSynchronisationSupported extends DeploymentSynchronisationSuppor } case object NoDeploymentSynchronisationSupport extends DeploymentSynchronisationSupport + +sealed trait SchedulingSupport + +trait SchedulingSupported extends SchedulingSupport { + + def createScheduledExecutionPerformer( + modelData: BaseModelData, + dependencies: DeploymentManagerDependencies, + deploymentConfig: Config, + ): ScheduledExecutionPerformer + + def customSchedulePropertyExtractorFactory: Option[SchedulePropertyExtractorFactory] + + def customProcessConfigEnricherFactory: Option[ProcessConfigEnricherFactory] + + def customScheduledProcessListenerFactory: Option[ScheduledProcessListenerFactory] + + def customAdditionalDeploymentDataProvider: Option[AdditionalDeploymentDataProvider] + +} + +case object NoSchedulingSupport extends SchedulingSupport diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/cache/CachingProcessStateDeploymentManager.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/cache/CachingProcessStateDeploymentManager.scala index 97ca468a2ec..1689041c83b 100644 --- a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/cache/CachingProcessStateDeploymentManager.scala +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/cache/CachingProcessStateDeploymentManager.scala @@ -16,6 +16,7 @@ class CachingProcessStateDeploymentManager( cacheTTL: FiniteDuration, override val deploymentSynchronisationSupport: DeploymentSynchronisationSupport, override val stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport, + override val schedulingSupport: SchedulingSupport, ) extends DeploymentManager { private val cache: AsyncCache[ProcessName, List[StatusDetails]] = Caffeine @@ -83,7 +84,8 @@ object CachingProcessStateDeploymentManager extends LazyLogging { delegate, cacheTTL, delegate.deploymentSynchronisationSupport, - delegate.stateQueryForAllScenariosSupport + delegate.stateQueryForAllScenariosSupport, + delegate.schedulingSupport, ) } .getOrElse { diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/DeploymentWithRuntimeParams.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/DeploymentWithRuntimeParams.scala new file mode 100644 index 00000000000..a7d30f9c982 --- /dev/null +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/DeploymentWithRuntimeParams.scala @@ -0,0 +1,12 @@ +package pl.touk.nussknacker.engine.api.deployment.scheduler.model + +import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} + +final case class DeploymentWithRuntimeParams( + processId: Option[ProcessId], + processName: ProcessName, + versionId: VersionId, + runtimeParams: RuntimeParams, +) + +final case class RuntimeParams(params: Map[String, String]) diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduleProperty.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduleProperty.scala new file mode 100644 index 00000000000..4b8a1f7826b --- /dev/null +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduleProperty.scala @@ -0,0 +1,16 @@ +package pl.touk.nussknacker.engine.api.deployment.scheduler.model + +sealed trait ScheduleProperty + +object ScheduleProperty { + sealed trait SingleScheduleProperty extends ScheduleProperty + + final case class MultipleScheduleProperty( + schedules: Map[String, SingleScheduleProperty] + ) extends ScheduleProperty + + final case class CronScheduleProperty( + labelOrCronExpr: String + ) extends SingleScheduleProperty + +} diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduledDeploymentDetails.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduledDeploymentDetails.scala new file mode 100644 index 00000000000..3d784ac7f0a --- /dev/null +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduledDeploymentDetails.scala @@ -0,0 +1,28 @@ +package pl.touk.nussknacker.engine.api.deployment.scheduler.model + +import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId} + +import java.time.LocalDateTime + +case class ScheduledDeploymentDetails( + id: Long, + processName: ProcessName, + versionId: VersionId, + scheduleName: Option[String], + createdAt: LocalDateTime, + runAt: LocalDateTime, + deployedAt: Option[LocalDateTime], + completedAt: Option[LocalDateTime], + status: ScheduledDeploymentStatus, +) + +sealed trait ScheduledDeploymentStatus + +object ScheduledDeploymentStatus { + case object Scheduled extends ScheduledDeploymentStatus + case object Deployed extends ScheduledDeploymentStatus + case object Finished extends ScheduledDeploymentStatus + case object Failed extends ScheduledDeploymentStatus + case object RetryingDeploy extends ScheduledDeploymentStatus + case object FailedOnDeploy extends ScheduledDeploymentStatus +} diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduledProcessDetails.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduledProcessDetails.scala new file mode 100644 index 00000000000..8100c6f33c7 --- /dev/null +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/model/ScheduledProcessDetails.scala @@ -0,0 +1,9 @@ +package pl.touk.nussknacker.engine.api.deployment.scheduler.model + +import pl.touk.nussknacker.engine.api.{MetaData, ProcessVersion} + +case class ScheduledProcessDetails( + processVersion: ProcessVersion, + processMetaData: MetaData, + inputConfigDuringExecutionJson: String, +) diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/AdditionalDeploymentDataProvider.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/AdditionalDeploymentDataProvider.scala new file mode 100644 index 00000000000..158836ce6ca --- /dev/null +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/AdditionalDeploymentDataProvider.scala @@ -0,0 +1,9 @@ +package pl.touk.nussknacker.engine.api.deployment.scheduler.services + +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.ScheduledDeploymentDetails + +trait AdditionalDeploymentDataProvider { + + def prepareAdditionalData(runDetails: ScheduledDeploymentDetails): Map[String, String] + +} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/ProcessConfigEnricher.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ProcessConfigEnricher.scala similarity index 77% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/ProcessConfigEnricher.scala rename to designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ProcessConfigEnricher.scala index e428dbd5ba4..0e89cd851cb 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/ProcessConfigEnricher.scala +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ProcessConfigEnricher.scala @@ -1,14 +1,10 @@ -package pl.touk.nussknacker.engine.management.periodic.service +package pl.touk.nussknacker.engine.api.deployment.scheduler.services import com.typesafe.config.{Config, ConfigFactory} +import pl.touk.nussknacker.engine.api.ProcessVersion +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{ScheduledDeploymentDetails, ScheduledProcessDetails} +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.ProcessConfigEnricher.{DeployData, EnrichedProcessConfig, InitialScheduleData} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithCanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeployment -import pl.touk.nussknacker.engine.management.periodic.service.ProcessConfigEnricher.{ - DeployData, - EnrichedProcessConfig, - InitialScheduleData -} import pl.touk.nussknacker.engine.modelconfig.InputConfigDuringExecution import sttp.client3.SttpBackend @@ -32,7 +28,6 @@ trait ProcessConfigEnricher { object ProcessConfigEnricher { trait ProcessConfigEnricherInputData { - def canonicalProcess: CanonicalProcess def inputConfigDuringExecutionJson: String def inputConfigDuringExecution: Config = { @@ -41,13 +36,16 @@ object ProcessConfigEnricher { } - case class InitialScheduleData(canonicalProcess: CanonicalProcess, inputConfigDuringExecutionJson: String) - extends ProcessConfigEnricherInputData + case class InitialScheduleData( + canonicalProcess: CanonicalProcess, + inputConfigDuringExecutionJson: String + ) extends ProcessConfigEnricherInputData case class DeployData( - canonicalProcess: CanonicalProcess, - inputConfigDuringExecutionJson: String, - deployment: PeriodicProcessDeployment[WithCanonicalProcess] + canonicalProcess: CanonicalProcess, + processVersion: ProcessVersion, + inputConfigDuringExecutionJson: String, + deploymentDetails: ScheduledDeploymentDetails, ) extends ProcessConfigEnricherInputData case class EnrichedProcessConfig(inputConfigDuringExecutionJson: String) diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/SchedulePropertyExtractorFactory.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/SchedulePropertyExtractorFactory.scala new file mode 100644 index 00000000000..112c14ea68a --- /dev/null +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/SchedulePropertyExtractorFactory.scala @@ -0,0 +1,13 @@ +package pl.touk.nussknacker.engine.api.deployment.scheduler.services + +import com.typesafe.config.Config +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.ScheduleProperty +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess + +trait SchedulePropertyExtractorFactory { + def apply(config: Config): SchedulePropertyExtractor +} + +trait SchedulePropertyExtractor { + def apply(canonicalProcess: CanonicalProcess): Either[String, ScheduleProperty] +} diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ScheduledExecutionPerformer.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ScheduledExecutionPerformer.scala new file mode 100644 index 00000000000..d971c640d48 --- /dev/null +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ScheduledExecutionPerformer.scala @@ -0,0 +1,31 @@ +package pl.touk.nussknacker.engine.api.deployment.scheduler.services + +import pl.touk.nussknacker.engine.api.ProcessVersion +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{DeploymentWithRuntimeParams, RuntimeParams} +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.engine.deployment.{DeploymentData, ExternalDeploymentId} +import pl.touk.nussknacker.engine.modelconfig.InputConfigDuringExecution + +import scala.concurrent.Future + +trait ScheduledExecutionPerformer { + + def provideInputConfigDuringExecutionJson(): Future[InputConfigDuringExecution] + + def prepareDeploymentWithRuntimeParams( + processVersion: ProcessVersion, + ): Future[DeploymentWithRuntimeParams] + + def deployWithRuntimeParams( + deploymentWithJarData: DeploymentWithRuntimeParams, + inputConfigDuringExecutionJson: String, + deploymentData: DeploymentData, + canonicalProcess: CanonicalProcess, + processVersion: ProcessVersion, + ): Future[Option[ExternalDeploymentId]] + + def cleanAfterDeployment( + runtimeParams: RuntimeParams + ): Future[Unit] + +} diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ScheduledProcessListener.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ScheduledProcessListener.scala new file mode 100644 index 00000000000..b1653dedff3 --- /dev/null +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/api/deployment/scheduler/services/ScheduledProcessListener.scala @@ -0,0 +1,60 @@ +package pl.touk.nussknacker.engine.api.deployment.scheduler.services + +import com.typesafe.config.Config +import pl.touk.nussknacker.engine.api.deployment.StatusDetails +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.ScheduledDeploymentDetails +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.engine.deployment.ExternalDeploymentId + +/* + Listener is at-least-once. If there are problems e.g. with DB, invocation can be repeated for same event. + Implementation should be aware of that. Listener is invoked during DB transaction, for that reason it's *synchronous* + */ +trait ScheduledProcessListener { + + def onScheduledProcessEvent: PartialFunction[ScheduledProcessEvent, Unit] + def close(): Unit = {} +} + +trait ScheduledProcessListenerFactory { + def create(config: Config): ScheduledProcessListener +} + +sealed trait ScheduledProcessEvent { + val deployment: ScheduledDeploymentDetails +} + +case class DeployedEvent( + deployment: ScheduledDeploymentDetails, + externalDeploymentId: Option[ExternalDeploymentId] +) extends ScheduledProcessEvent + +case class FinishedEvent( + deployment: ScheduledDeploymentDetails, + canonicalProcess: CanonicalProcess, + processState: Option[StatusDetails] +) extends ScheduledProcessEvent + +case class FailedOnDeployEvent( + deployment: ScheduledDeploymentDetails, + processState: Option[StatusDetails] +) extends ScheduledProcessEvent + +case class FailedOnRunEvent( + deployment: ScheduledDeploymentDetails, + processState: Option[StatusDetails] +) extends ScheduledProcessEvent + +case class ScheduledEvent(deployment: ScheduledDeploymentDetails, firstSchedule: Boolean) extends ScheduledProcessEvent + +object EmptyListener extends EmptyListener + +trait EmptyListener extends ScheduledProcessListener { + + override def onScheduledProcessEvent: PartialFunction[ScheduledProcessEvent, Unit] = Map.empty + +} + +object EmptyScheduledProcessListenerFactory extends ScheduledProcessListenerFactory { + override def create(config: Config): ScheduledProcessListener = EmptyListener +} diff --git a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/testing/DeploymentManagerStub.scala b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/testing/DeploymentManagerStub.scala index 3fad23ad8a9..1919c1d4b35 100644 --- a/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/testing/DeploymentManagerStub.scala +++ b/designer/deployment-manager-api/src/main/scala/pl/touk/nussknacker/engine/testing/DeploymentManagerStub.scala @@ -61,6 +61,8 @@ class DeploymentManagerStub extends BaseDeploymentManager with StubbingCommands override def stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport = NoStateQueryForAllScenariosSupport + override def schedulingSupport: SchedulingSupport = NoSchedulingSupport + override def close(): Unit = {} } diff --git a/designer/deployment-manager-api/src/test/scala/pl/touk/nussknacker/engine/api/deployment/cache/CachingProcessStateDeploymentManagerSpec.scala b/designer/deployment-manager-api/src/test/scala/pl/touk/nussknacker/engine/api/deployment/cache/CachingProcessStateDeploymentManagerSpec.scala index 172d141e792..c24c358ff9e 100644 --- a/designer/deployment-manager-api/src/test/scala/pl/touk/nussknacker/engine/api/deployment/cache/CachingProcessStateDeploymentManagerSpec.scala +++ b/designer/deployment-manager-api/src/test/scala/pl/touk/nussknacker/engine/api/deployment/cache/CachingProcessStateDeploymentManagerSpec.scala @@ -30,7 +30,8 @@ class CachingProcessStateDeploymentManagerSpec delegate, 10 seconds, NoDeploymentSynchronisationSupport, - NoStateQueryForAllScenariosSupport + NoStateQueryForAllScenariosSupport, + NoSchedulingSupport, ) val results = List( @@ -49,7 +50,8 @@ class CachingProcessStateDeploymentManagerSpec delegate, 10 seconds, NoDeploymentSynchronisationSupport, - NoStateQueryForAllScenariosSupport + NoStateQueryForAllScenariosSupport, + NoSchedulingSupport, ) val firstInvocation = cachingManager.getProcessStatesDeploymentIdNow(DataFreshnessPolicy.CanBeCached) @@ -67,7 +69,8 @@ class CachingProcessStateDeploymentManagerSpec delegate, 10 seconds, NoDeploymentSynchronisationSupport, - NoStateQueryForAllScenariosSupport + NoStateQueryForAllScenariosSupport, + NoSchedulingSupport, ) val resultForFresh = cachingManager.getProcessStatesDeploymentIdNow(DataFreshnessPolicy.Fresh) diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/common/V1_103__drop_buildinfo.sql b/designer/server/src/main/resources/db/batch_periodic/migration/common/V1_103__drop_buildinfo.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/common/V1_103__drop_buildinfo.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/common/V1_103__drop_buildinfo.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/common/V1_105__add_indexes.sql b/designer/server/src/main/resources/db/batch_periodic/migration/common/V1_105__add_indexes.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/common/V1_105__add_indexes.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/common/V1_105__add_indexes.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/common/V1_107__add_retry_at_and_retries_to_periodic_process_deployments.sql b/designer/server/src/main/resources/db/batch_periodic/migration/common/V1_107__add_retry_at_and_retries_to_periodic_process_deployments.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/common/V1_107__add_retry_at_and_retries_to_periodic_process_deployments.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/common/V1_107__add_retry_at_and_retries_to_periodic_process_deployments.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/common/V1_109__add_action_id_to_periodic_processes.sql b/designer/server/src/main/resources/db/batch_periodic/migration/common/V1_109__add_action_id_to_periodic_processes.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/common/V1_109__add_action_id_to_periodic_processes.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/common/V1_109__add_action_id_to_periodic_processes.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_101__create_batch_periodic_tables.sql b/designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_101__create_batch_periodic_tables.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_101__create_batch_periodic_tables.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_101__create_batch_periodic_tables.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_102__add_program_args_and_jar_id.sql b/designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_102__add_program_args_and_jar_id.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_102__add_program_args_and_jar_id.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_102__add_program_args_and_jar_id.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_104__multiple_schedules.sql b/designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_104__multiple_schedules.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_104__multiple_schedules.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_104__multiple_schedules.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_106__rename_model_config.sql b/designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_106__rename_model_config.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_106__rename_model_config.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_106__rename_model_config.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_108__add_processing_type.sql b/designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_108__add_processing_type.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/hsql/V1_108__add_processing_type.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/hsql/V1_108__add_processing_type.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_101__create_batch_periodic_tables.sql b/designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_101__create_batch_periodic_tables.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_101__create_batch_periodic_tables.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_101__create_batch_periodic_tables.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_102__add_program_args_and_jar_id.sql b/designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_102__add_program_args_and_jar_id.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_102__add_program_args_and_jar_id.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_102__add_program_args_and_jar_id.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_104__multiple_schedules.sql b/designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_104__multiple_schedules.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_104__multiple_schedules.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_104__multiple_schedules.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_106__rename_model_config.sql b/designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_106__rename_model_config.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_106__rename_model_config.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_106__rename_model_config.sql diff --git a/engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_108__add_processing_type.sql b/designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_108__add_processing_type.sql similarity index 100% rename from engine/flink/management/periodic/src/main/resources/db/batch_periodic/migration/postgres/V1_108__add_processing_type.sql rename to designer/server/src/main/resources/db/batch_periodic/migration/postgres/V1_108__add_processing_type.sql diff --git a/engine/flink/management/periodic/src/main/resources/web/static/assets/states/scheduled.svg b/designer/server/src/main/resources/web/static/assets/states/scheduled.svg similarity index 100% rename from engine/flink/management/periodic/src/main/resources/web/static/assets/states/scheduled.svg rename to designer/server/src/main/resources/web/static/assets/states/scheduled.svg diff --git a/engine/flink/management/periodic/src/main/resources/web/static/assets/states/wait-reschedule.svg b/designer/server/src/main/resources/web/static/assets/states/wait-reschedule.svg similarity index 100% rename from engine/flink/management/periodic/src/main/resources/web/static/assets/states/wait-reschedule.svg rename to designer/server/src/main/resources/web/static/assets/states/wait-reschedule.svg diff --git a/designer/server/src/main/scala/db/migration/V1_061__PeriodicDeploymentManagerTablesDefinition.scala b/designer/server/src/main/scala/db/migration/V1_061__PeriodicDeploymentManagerTablesDefinition.scala new file mode 100644 index 00000000000..75279963168 --- /dev/null +++ b/designer/server/src/main/scala/db/migration/V1_061__PeriodicDeploymentManagerTablesDefinition.scala @@ -0,0 +1,153 @@ +package db.migration + +import com.typesafe.scalalogging.LazyLogging +import db.migration.V1_061__PeriodicDeploymentManagerTablesDefinition.Definitions +import pl.touk.nussknacker.ui.db.migration.SlickMigration +import slick.jdbc.JdbcProfile +import slick.lifted.ProvenShape +import slick.sql.SqlProfile.ColumnOption.NotNull + +import java.time.LocalDateTime +import java.util.UUID +import scala.concurrent.ExecutionContext.Implicits.global + +trait V1_061__PeriodicDeploymentManagerTablesDefinition extends SlickMigration with LazyLogging { + + import profile.api._ + + private val definitions = new Definitions(profile) + + override def migrateActions: DBIOAction[Any, NoStream, Effect.All] = { + logger.info("Starting migration V1_061__PeriodicDeploymentManagerTablesDefinition") + for { + _ <- definitions.periodicProcessesTable.schema.create + _ <- definitions.periodicProcessDeploymentsTable.schema.create + } yield () + } + +} + +object V1_061__PeriodicDeploymentManagerTablesDefinition { + + class Definitions(val profile: JdbcProfile) { + import profile.api._ + + val periodicProcessDeploymentsTable = TableQuery[PeriodicProcessDeploymentsTable] + + class PeriodicProcessDeploymentsTable(tag: Tag) + extends Table[PeriodicProcessDeploymentEntity](tag, "periodic_scenario_deployments") { + + def id: Rep[Long] = column[Long]("id", O.PrimaryKey, O.AutoInc) + + def periodicProcessId: Rep[Long] = column[Long]("periodic_process_id", NotNull) + + def createdAt: Rep[LocalDateTime] = column[LocalDateTime]("created_at", NotNull) + + def runAt: Rep[LocalDateTime] = column[LocalDateTime]("run_at", NotNull) + + def scheduleName: Rep[Option[String]] = column[Option[String]]("schedule_name") + + def deployedAt: Rep[Option[LocalDateTime]] = column[Option[LocalDateTime]]("deployed_at") + + def completedAt: Rep[Option[LocalDateTime]] = column[Option[LocalDateTime]]("completed_at") + + def retriesLeft: Rep[Int] = column[Int]("retries_left") + + def nextRetryAt: Rep[Option[LocalDateTime]] = column[Option[LocalDateTime]]("next_retry_at") + + def status: Rep[String] = column[String]("status", NotNull) + + def periodicProcessIdIndex = index("periodic_scenario_deployments_periodic_process_id_idx", periodicProcessId) + def createdAtIndex = index("periodic_scenario_deployments_created_at_idx", createdAt) + def runAtIndex = index("periodic_scenario_deployments_run_at_idx", runAt) + + override def * : ProvenShape[PeriodicProcessDeploymentEntity] = ( + id, + periodicProcessId, + createdAt, + runAt, + scheduleName, + deployedAt, + completedAt, + retriesLeft, + nextRetryAt, + status + ) <> + ((PeriodicProcessDeploymentEntity.apply _).tupled, PeriodicProcessDeploymentEntity.unapply) + + } + + case class PeriodicProcessDeploymentEntity( + id: Long, + periodicProcessId: Long, + createdAt: LocalDateTime, + runAt: LocalDateTime, + scheduleName: Option[String], + deployedAt: Option[LocalDateTime], + completedAt: Option[LocalDateTime], + retriesLeft: Int, + nextRetryAt: Option[LocalDateTime], + status: String + ) + + val periodicProcessesTable = TableQuery[PeriodicProcessesTable] + + class PeriodicProcessesTable(tag: Tag) extends Table[PeriodicProcessEntity](tag, "periodic_scenarios") { + + def periodicProcessId: Rep[Long] = column[Long]("id", O.Unique, O.AutoInc) + + def processId: Rep[Option[Long]] = column[Option[Long]]("process_id") + + def processName: Rep[String] = column[String]("process_name", NotNull) + + def processVersionId: Rep[Long] = column[Long]("process_version_id", NotNull) + + def processingType: Rep[String] = column[String]("processing_type", NotNull) + + def runtimeParams: Rep[String] = column[String]("runtime_params") + + def scheduleProperty: Rep[String] = column[String]("schedule_property", NotNull) + + def active: Rep[Boolean] = column[Boolean]("active", NotNull) + + def createdAt: Rep[LocalDateTime] = column[LocalDateTime]("created_at", NotNull) + + def processActionId: Rep[Option[UUID]] = column[Option[UUID]]("process_action_id") + + def inputConfigDuringExecutionJson: Rep[String] = column[String]("input_config_during_execution", NotNull) + + def processNameAndActiveIndex = index("periodic_scenarios_process_name_active_idx", (processName, active)) + + override def * : ProvenShape[PeriodicProcessEntity] = ( + periodicProcessId, + processId, + processName, + processVersionId, + processingType, + runtimeParams, + scheduleProperty, + active, + createdAt, + processActionId, + inputConfigDuringExecutionJson, + ) <> (PeriodicProcessEntity.apply _ tupled, PeriodicProcessEntity.unapply) + + } + + case class PeriodicProcessEntity( + id: Long, + processId: Option[Long], + processName: String, + processVersionId: Long, + processingType: String, + runtimeParams: String, + scheduleProperty: String, + active: Boolean, + createdAt: LocalDateTime, + processActionId: Option[UUID], + inputConfigDuringExecutionJson: String, + ) + + } + +} diff --git a/designer/server/src/main/scala/db/migration/hsql/V1_061__PeriodicDeploymentManagerTables.scala b/designer/server/src/main/scala/db/migration/hsql/V1_061__PeriodicDeploymentManagerTables.scala new file mode 100644 index 00000000000..350e8fdc9e0 --- /dev/null +++ b/designer/server/src/main/scala/db/migration/hsql/V1_061__PeriodicDeploymentManagerTables.scala @@ -0,0 +1,8 @@ +package db.migration.hsql + +import db.migration.V1_061__PeriodicDeploymentManagerTablesDefinition +import slick.jdbc.{HsqldbProfile, JdbcProfile} + +class V1_061__PeriodicDeploymentManagerTables extends V1_061__PeriodicDeploymentManagerTablesDefinition { + override protected lazy val profile: JdbcProfile = HsqldbProfile +} diff --git a/designer/server/src/main/scala/db/migration/postgres/V1_061__PeriodicDeploymentManagerTables.scala b/designer/server/src/main/scala/db/migration/postgres/V1_061__PeriodicDeploymentManagerTables.scala new file mode 100644 index 00000000000..97271414d93 --- /dev/null +++ b/designer/server/src/main/scala/db/migration/postgres/V1_061__PeriodicDeploymentManagerTables.scala @@ -0,0 +1,8 @@ +package db.migration.postgres + +import db.migration.V1_061__PeriodicDeploymentManagerTablesDefinition +import slick.jdbc.{JdbcProfile, PostgresProfile} + +class V1_061__PeriodicDeploymentManagerTables extends V1_061__PeriodicDeploymentManagerTablesDefinition { + override protected lazy val profile: JdbcProfile = PostgresProfile +} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessDeploymentsTable.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/PeriodicProcessDeploymentsTable.scala similarity index 84% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessDeploymentsTable.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/PeriodicProcessDeploymentsTable.scala index f9b1bacb69b..8c82157e36d 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessDeploymentsTable.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/PeriodicProcessDeploymentsTable.scala @@ -1,7 +1,7 @@ -package pl.touk.nussknacker.engine.management.periodic.db +package pl.touk.nussknacker.ui.db.entity -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus -import pl.touk.nussknacker.engine.management.periodic.model.{ +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus +import pl.touk.nussknacker.ui.process.periodic.model.{ PeriodicProcessDeploymentId, PeriodicProcessDeploymentStatus, PeriodicProcessId @@ -14,15 +14,16 @@ import java.time.LocalDateTime trait PeriodicProcessDeploymentsTableFactory extends PeriodicProcessesTableFactory { - protected val profile: JdbcProfile - import profile.api._ + implicit val periodicProcessDeploymentIdMapping: BaseColumnType[PeriodicProcessDeploymentId] = + MappedColumnType.base[PeriodicProcessDeploymentId, Long](_.value, PeriodicProcessDeploymentId.apply) + implicit val periodicProcessDeploymentStatusColumnTyped: JdbcType[PeriodicProcessDeploymentStatus] = MappedColumnType.base[PeriodicProcessDeploymentStatus, String](_.toString, PeriodicProcessDeploymentStatus.withName) class PeriodicProcessDeploymentsTable(tag: Tag) - extends Table[PeriodicProcessDeploymentEntity](tag, "periodic_process_deployments") { + extends Table[PeriodicProcessDeploymentEntity](tag, "periodic_scenario_deployments") { def id: Rep[PeriodicProcessDeploymentId] = column[PeriodicProcessDeploymentId]("id", O.PrimaryKey, O.AutoInc) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/PeriodicProcessesTable.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/PeriodicProcessesTable.scala new file mode 100644 index 00000000000..f4d7c968a50 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/db/entity/PeriodicProcessesTable.scala @@ -0,0 +1,160 @@ +package pl.touk.nussknacker.ui.db.entity + +import io.circe.Decoder +import io.circe.syntax.EncoderOps +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.RuntimeParams +import pl.touk.nussknacker.engine.api.deployment.ProcessActionId +import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessId +import slick.jdbc.JdbcProfile +import slick.lifted.ProvenShape +import slick.sql.SqlProfile.ColumnOption.NotNull + +import java.time.LocalDateTime +import java.util.UUID + +trait PeriodicProcessesTableFactory extends BaseEntityFactory { + + protected val profile: JdbcProfile + + import profile.api._ + + implicit val periodicProcessIdMapping: BaseColumnType[PeriodicProcessId] = + MappedColumnType.base[PeriodicProcessId, Long](_.value, PeriodicProcessId.apply) + + private implicit val processActionIdTypedType: BaseColumnType[ProcessActionId] = + MappedColumnType.base[ProcessActionId, UUID]( + _.value, + ProcessActionId(_) + ) + + implicit val runtimeParamsTypedType: BaseColumnType[RuntimeParams] = + MappedColumnType.base[RuntimeParams, String]( + _.params.asJson.noSpaces, + jsonStr => + io.circe.parser.parse(jsonStr).flatMap(Decoder[Map[String, String]].decodeJson) match { + case Right(params) => RuntimeParams(params) + case Left(error) => throw error + } + ) + + abstract class PeriodicProcessesTable[ENTITY <: PeriodicProcessEntity](tag: Tag) + extends Table[ENTITY](tag, "periodic_scenarios") { + + def id: Rep[PeriodicProcessId] = column[PeriodicProcessId]("id", O.PrimaryKey, O.AutoInc) + + def processId: Rep[Option[ProcessId]] = column[Option[ProcessId]]("process_id") + + def processName: Rep[ProcessName] = column[ProcessName]("process_name", NotNull) + + def processVersionId: Rep[VersionId] = column[VersionId]("process_version_id", NotNull) + + def processingType: Rep[String] = column[String]("processing_type", NotNull) + + def runtimeParams: Rep[RuntimeParams] = column[RuntimeParams]("runtime_params") + + def scheduleProperty: Rep[String] = column[String]("schedule_property", NotNull) + + def active: Rep[Boolean] = column[Boolean]("active", NotNull) + + def createdAt: Rep[LocalDateTime] = column[LocalDateTime]("created_at", NotNull) + + def processActionId: Rep[Option[ProcessActionId]] = column[Option[ProcessActionId]]("process_action_id") + + } + + class PeriodicProcessesWithInputConfigJsonTable(tag: Tag) + extends PeriodicProcessesTable[PeriodicProcessEntityWithInputConfigJson](tag) { + + def inputConfigDuringExecutionJson: Rep[String] = column[String]("input_config_during_execution", NotNull) + + override def * : ProvenShape[PeriodicProcessEntityWithInputConfigJson] = ( + id, + processId, + processName, + processVersionId, + processingType, + runtimeParams, + scheduleProperty, + active, + createdAt, + processActionId, + inputConfigDuringExecutionJson, + ) <> (PeriodicProcessEntityWithInputConfigJson.apply _ tupled, PeriodicProcessEntityWithInputConfigJson.unapply) + + } + + class PeriodicProcessesWithoutInputConfigJsonTable(tag: Tag) + extends PeriodicProcessesTable[PeriodicProcessEntityWithoutInputConfigJson](tag) { + + override def * : ProvenShape[PeriodicProcessEntityWithoutInputConfigJson] = ( + id, + processId, + processName, + processVersionId, + processingType, + runtimeParams, + scheduleProperty, + active, + createdAt, + processActionId + ) <> (PeriodicProcessEntityWithoutInputConfigJson.apply _ tupled, PeriodicProcessEntityWithoutInputConfigJson.unapply) + + } + + object PeriodicProcessesWithoutInputConfig extends TableQuery(new PeriodicProcessesWithoutInputConfigJsonTable(_)) + + object PeriodicProcessesWithInputConfig extends TableQuery(new PeriodicProcessesWithInputConfigJsonTable(_)) + +} + +trait PeriodicProcessEntity { + + def id: PeriodicProcessId + + def processId: Option[ProcessId] + + def processName: ProcessName + + def processVersionId: VersionId + + def processingType: String + + def runtimeParams: RuntimeParams + + def scheduleProperty: String + + def active: Boolean + + def createdAt: LocalDateTime + + def processActionId: Option[ProcessActionId] + +} + +case class PeriodicProcessEntityWithInputConfigJson( + id: PeriodicProcessId, + processId: Option[ProcessId], + processName: ProcessName, + processVersionId: VersionId, + processingType: String, + runtimeParams: RuntimeParams, + scheduleProperty: String, + active: Boolean, + createdAt: LocalDateTime, + processActionId: Option[ProcessActionId], + inputConfigDuringExecutionJson: String, +) extends PeriodicProcessEntity + +case class PeriodicProcessEntityWithoutInputConfigJson( + id: PeriodicProcessId, + processId: Option[ProcessId], + processName: ProcessName, + processVersionId: VersionId, + processingType: String, + runtimeParams: RuntimeParams, + scheduleProperty: String, + active: Boolean, + createdAt: LocalDateTime, + processActionId: Option[ProcessActionId] +) extends PeriodicProcessEntity diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/DefaultAdditionalDeploymentDataProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/DefaultAdditionalDeploymentDataProvider.scala new file mode 100644 index 00000000000..c82cd46b59c --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/DefaultAdditionalDeploymentDataProvider.scala @@ -0,0 +1,18 @@ +package pl.touk.nussknacker.ui.process.periodic + +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.ScheduledDeploymentDetails +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.AdditionalDeploymentDataProvider + +import java.time.format.DateTimeFormatter + +object DefaultAdditionalDeploymentDataProvider extends AdditionalDeploymentDataProvider { + + override def prepareAdditionalData(runDetails: ScheduledDeploymentDetails): Map[String, String] = { + Map( + "deploymentId" -> runDetails.id.toString, + "runAt" -> runDetails.runAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), + "scheduleName" -> runDetails.scheduleName.getOrElse("") + ) + } + +} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/DeploymentActor.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/DeploymentActor.scala similarity index 70% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/DeploymentActor.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/DeploymentActor.scala index 1beb82e32ac..d8361fbbb9e 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/DeploymentActor.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/DeploymentActor.scala @@ -1,15 +1,9 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import akka.actor.{Actor, Props, Timers} import com.typesafe.scalalogging.LazyLogging -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.DeploymentActor.{ - CheckToBeDeployed, - DeploymentCompleted, - WaitingForDeployment -} -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithCanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeployment +import pl.touk.nussknacker.ui.process.periodic.DeploymentActor._ +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeployment import scala.concurrent.Future import scala.concurrent.duration._ @@ -22,8 +16,8 @@ object DeploymentActor { } private[periodic] def props( - findToBeDeployed: => Future[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]], - deploy: PeriodicProcessDeployment[WithCanonicalProcess] => Future[Unit], + findToBeDeployed: => Future[Seq[PeriodicProcessDeployment]], + deploy: PeriodicProcessDeployment => Future[Unit], interval: FiniteDuration ) = { Props(new DeploymentActor(findToBeDeployed, deploy, interval)) @@ -31,14 +25,14 @@ object DeploymentActor { private[periodic] case object CheckToBeDeployed - private case class WaitingForDeployment(ids: List[PeriodicProcessDeployment[WithCanonicalProcess]]) + private case class WaitingForDeployment(ids: List[PeriodicProcessDeployment]) private case object DeploymentCompleted } class DeploymentActor( - findToBeDeployed: => Future[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]], - deploy: PeriodicProcessDeployment[WithCanonicalProcess] => Future[Unit], + findToBeDeployed: => Future[Seq[PeriodicProcessDeployment]], + deploy: PeriodicProcessDeployment => Future[Unit], interval: FiniteDuration ) extends Actor with Timers @@ -74,7 +68,9 @@ class DeploymentActor( } } - private def receiveOngoingDeployment(runDetails: PeriodicProcessDeployment[WithCanonicalProcess]): Receive = { + private def receiveOngoingDeployment( + runDetails: PeriodicProcessDeployment + ): Receive = { case CheckToBeDeployed => logger.debug(s"Still waiting for ${runDetails.display} to be deployed") case DeploymentCompleted => diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicDeploymentManager.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicDeploymentManager.scala similarity index 81% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicDeploymentManager.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicDeploymentManager.scala index d5bac21c6a1..5242de64a68 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicDeploymentManager.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicDeploymentManager.scala @@ -1,29 +1,18 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import cats.data.OptionT import com.typesafe.config.Config import com.typesafe.scalalogging.LazyLogging +import pl.touk.nussknacker.engine.DeploymentManagerDependencies import pl.touk.nussknacker.engine.api.deployment._ +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{ScheduleProperty => ApiScheduleProperty} +import pl.touk.nussknacker.engine.api.deployment.scheduler.services._ import pl.touk.nussknacker.engine.api.process.{ProcessIdWithName, ProcessName, VersionId} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.engine.deployment.ExternalDeploymentId -import pl.touk.nussknacker.engine.management.FlinkConfig -import pl.touk.nussknacker.engine.management.periodic.PeriodicProcessService.PeriodicProcessStatus -import pl.touk.nussknacker.engine.management.periodic.Utils.{createActorWithRetry, runSafely} -import pl.touk.nussknacker.engine.management.periodic.db.{ - DbInitializer, - PeriodicProcessesRepository, - SlickPeriodicProcessesRepository -} -import pl.touk.nussknacker.engine.management.periodic.flink.FlinkJarManager -import pl.touk.nussknacker.engine.management.periodic.service.{ - AdditionalDeploymentDataProvider, - PeriodicProcessListenerFactory, - ProcessConfigEnricherFactory -} -import pl.touk.nussknacker.engine.{BaseModelData, DeploymentManagerDependencies} -import slick.jdbc -import slick.jdbc.JdbcProfile +import pl.touk.nussknacker.ui.process.periodic.PeriodicProcessService.PeriodicProcessStatus +import pl.touk.nussknacker.ui.process.periodic.Utils._ +import pl.touk.nussknacker.ui.process.repository.PeriodicProcessesRepository import java.time.{Clock, Instant} import scala.concurrent.{ExecutionContext, Future} @@ -32,51 +21,46 @@ object PeriodicDeploymentManager { def apply( delegate: DeploymentManager, + scheduledExecutionPerformer: ScheduledExecutionPerformer, schedulePropertyExtractorFactory: SchedulePropertyExtractorFactory, processConfigEnricherFactory: ProcessConfigEnricherFactory, - periodicBatchConfig: PeriodicBatchConfig, - flinkConfig: FlinkConfig, + schedulingConfig: SchedulingConfig, originalConfig: Config, - modelData: BaseModelData, - listenerFactory: PeriodicProcessListenerFactory, + listenerFactory: ScheduledProcessListenerFactory, additionalDeploymentDataProvider: AdditionalDeploymentDataProvider, - dependencies: DeploymentManagerDependencies + dependencies: DeploymentManagerDependencies, + periodicProcessesRepository: PeriodicProcessesRepository, ): PeriodicDeploymentManager = { import dependencies._ - val clock = Clock.systemDefaultZone() - - val (db: jdbc.JdbcBackend.DatabaseDef, dbProfile: JdbcProfile) = DbInitializer.init(periodicBatchConfig.db) - val scheduledProcessesRepository = - new SlickPeriodicProcessesRepository(db, dbProfile, clock, periodicBatchConfig.processingType) - val jarManager = FlinkJarManager(flinkConfig, periodicBatchConfig, modelData) + val clock = Clock.systemDefaultZone() val listener = listenerFactory.create(originalConfig) val processConfigEnricher = processConfigEnricherFactory(originalConfig) val service = new PeriodicProcessService( delegate, - jarManager, - scheduledProcessesRepository, + scheduledExecutionPerformer, + periodicProcessesRepository, listener, additionalDeploymentDataProvider, - periodicBatchConfig.deploymentRetry, - periodicBatchConfig.executionConfig, - periodicBatchConfig.maxFetchedPeriodicScenarioActivities, + schedulingConfig.deploymentRetry, + schedulingConfig.executionConfig, + schedulingConfig.maxFetchedPeriodicScenarioActivities, processConfigEnricher, clock, dependencies.actionService, - dependencies.configsFromProvider + dependencies.configsFromProvider, ) // These actors have to be created with retries because they can initially fail to create due to taken names, // if the actors (with the same names) created before reload aren't fully stopped (and their names freed) yet val deploymentActor = createActorWithRetry( - s"periodic-${periodicBatchConfig.processingType}-deployer", - DeploymentActor.props(service, periodicBatchConfig.deployInterval), + s"periodic-${schedulingConfig.processingType}-deployer", + DeploymentActor.props(service, schedulingConfig.deployInterval), dependencies.actorSystem ) val rescheduleFinishedActor = createActorWithRetry( - s"periodic-${periodicBatchConfig.processingType}-rescheduler", - RescheduleFinishedActor.props(service, periodicBatchConfig.rescheduleCheckInterval), + s"periodic-${schedulingConfig.processingType}-rescheduler", + RescheduleFinishedActor.props(service, schedulingConfig.rescheduleCheckInterval), dependencies.actorSystem ) @@ -86,12 +70,11 @@ object PeriodicDeploymentManager { // they don't have any internal state, so stopping them non-gracefully is safe runSafely(dependencies.actorSystem.stop(deploymentActor)) runSafely(dependencies.actorSystem.stop(rescheduleFinishedActor)) - runSafely(db.close()) } new PeriodicDeploymentManager( delegate, service, - scheduledProcessesRepository, + periodicProcessesRepository, schedulePropertyExtractorFactory(originalConfig), toClose ) @@ -102,7 +85,7 @@ object PeriodicDeploymentManager { class PeriodicDeploymentManager private[periodic] ( val delegate: DeploymentManager, service: PeriodicProcessService, - repository: PeriodicProcessesRepository, + periodicProcessesRepository: PeriodicProcessesRepository, schedulePropertyExtractor: SchedulePropertyExtractor, toClose: () => Unit )(implicit val ec: ExecutionContext) @@ -110,7 +93,7 @@ class PeriodicDeploymentManager private[periodic] ( with ManagerSpecificScenarioActivitiesStoredByManager with LazyLogging { - import repository._ + import periodicProcessesRepository._ override def processCommand[Result](command: DMScenarioCommand[Result]): Future[Result] = command match { @@ -158,12 +141,27 @@ class PeriodicDeploymentManager private[periodic] ( private def extractScheduleProperty(canonicalProcess: CanonicalProcess): Future[ScheduleProperty] = { schedulePropertyExtractor(canonicalProcess) match { case Right(scheduleProperty) => - Future.successful(scheduleProperty) + Future.successful(toDomain(scheduleProperty)) case Left(error) => Future.failed(new PeriodicProcessException(error)) } } + private def toDomain( + apiScheduleProperty: ApiScheduleProperty, + ): ScheduleProperty = apiScheduleProperty match { + case property: ApiScheduleProperty.SingleScheduleProperty => + toDomain(property) + case ApiScheduleProperty.MultipleScheduleProperty(schedules) => + MultipleScheduleProperty(schedules.map { case (k, v) => (k, toDomain(v)) }) + } + + private def toDomain( + apiSingleScheduleProperty: ApiScheduleProperty.SingleScheduleProperty + ): SingleScheduleProperty = apiSingleScheduleProperty match { + case ApiScheduleProperty.CronScheduleProperty(labelOrCronExpr) => CronScheduleProperty(labelOrCronExpr) + } + private def stopScenario(command: DMStopScenarioCommand): Future[SavepointResult] = { import command._ service.deactivate(scenarioName).flatMap { deploymentIdsToStop => @@ -282,9 +280,10 @@ class PeriodicDeploymentManager private[periodic] ( .map(_.groupedByPeriodicProcess.headOption.flatMap(_.deployments.headOption)) ) processDeploymentWithProcessJson <- OptionT.liftF( - repository.findProcessData(processDeployment.id).run + periodicProcessesRepository.findProcessData(processDeployment.id).run ) _ <- OptionT.liftF(service.deploy(processDeploymentWithProcessJson)) } yield () + override def schedulingSupport: SchedulingSupport = NoSchedulingSupport } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicDeploymentManagerDecorator.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicDeploymentManagerDecorator.scala new file mode 100644 index 00000000000..e4b36398383 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicDeploymentManagerDecorator.scala @@ -0,0 +1,117 @@ +package pl.touk.nussknacker.ui.process.periodic + +import com.typesafe.config.Config +import com.typesafe.scalalogging.LazyLogging +import pl.touk.nussknacker.engine.api.component.ScenarioPropertyConfig +import pl.touk.nussknacker.engine.api.definition.{MandatoryParameterValidator, StringParameterEditor} +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.{ + EmptyScheduledProcessListenerFactory, + ProcessConfigEnricherFactory, + SchedulePropertyExtractorFactory +} +import pl.touk.nussknacker.engine.api.deployment.{DeploymentManager, SchedulingSupported} +import pl.touk.nussknacker.engine.{DeploymentManagerDependencies, ModelData} +import pl.touk.nussknacker.ui.db.DbRef +import pl.touk.nussknacker.ui.process.periodic.cron.{CronParameterValidator, CronSchedulePropertyExtractor} +import pl.touk.nussknacker.ui.process.periodic.legacy.db.{LegacyDbInitializer, SlickLegacyPeriodicProcessesRepository} +import pl.touk.nussknacker.ui.process.repository.{ + DBFetchingProcessRepository, + DbScenarioActionReadOnlyRepository, + ScenarioLabelsRepository, + SlickPeriodicProcessesRepository +} +import slick.jdbc +import slick.jdbc.JdbcProfile + +import java.time.Clock + +object PeriodicDeploymentManagerDecorator extends LazyLogging { + + def decorate( + underlying: DeploymentManager, + schedulingSupported: SchedulingSupported, + modelData: ModelData, + deploymentConfig: Config, + dependencies: DeploymentManagerDependencies, + dbRef: DbRef, + ): DeploymentManager = { + logger.info("Decorating DM with periodic functionality") + import dependencies._ + import net.ceedubs.ficus.Ficus._ + import net.ceedubs.ficus.readers.ArbitraryTypeReader._ + + val clock = Clock.systemDefaultZone() + + deploymentConfig.as[SchedulingConfig]("scheduling") + val rawSchedulingConfig = deploymentConfig.getConfig("scheduling") + val schedulingConfig = rawSchedulingConfig.as[SchedulingConfig] + + val schedulePropertyExtractorFactory: SchedulePropertyExtractorFactory = + schedulingSupported.customSchedulePropertyExtractorFactory + .getOrElse(_ => CronSchedulePropertyExtractor()) + + val processConfigEnricherFactory = + schedulingSupported.customProcessConfigEnricherFactory + .getOrElse(ProcessConfigEnricherFactory.noOp) + + val periodicProcessListenerFactory = + schedulingSupported.customScheduledProcessListenerFactory + .getOrElse(EmptyScheduledProcessListenerFactory) + + val additionalDeploymentDataProvider = + schedulingSupported.customAdditionalDeploymentDataProvider + .getOrElse(DefaultAdditionalDeploymentDataProvider) + + val actionRepository = + DbScenarioActionReadOnlyRepository.create(dbRef) + val scenarioLabelsRepository = + new ScenarioLabelsRepository(dbRef) + val fetchingProcessRepository = + DBFetchingProcessRepository.createFutureRepository(dbRef, actionRepository, scenarioLabelsRepository) + + val periodicProcessesRepository = schedulingConfig.legacyDb match { + case None => + new SlickPeriodicProcessesRepository( + schedulingConfig.processingType, + dbRef.db, + dbRef.profile, + clock, + fetchingProcessRepository + ) + case Some(customDbConfig) => + val (db: jdbc.JdbcBackend.DatabaseDef, dbProfile: JdbcProfile) = LegacyDbInitializer.init(customDbConfig) + new SlickLegacyPeriodicProcessesRepository( + schedulingConfig.processingType, + db, + dbProfile, + clock, + fetchingProcessRepository + ) + } + + PeriodicDeploymentManager( + delegate = underlying, + dependencies = dependencies, + periodicProcessesRepository = periodicProcessesRepository, + scheduledExecutionPerformer = + schedulingSupported.createScheduledExecutionPerformer(modelData, dependencies, deploymentConfig), + schedulePropertyExtractorFactory = schedulePropertyExtractorFactory, + processConfigEnricherFactory = processConfigEnricherFactory, + listenerFactory = periodicProcessListenerFactory, + schedulingConfig = schedulingConfig, + originalConfig = deploymentConfig, + additionalDeploymentDataProvider = additionalDeploymentDataProvider, + ) + } + + def additionalScenarioProperties: Map[String, ScenarioPropertyConfig] = Map(cronConfig) + + private val cronConfig = CronSchedulePropertyExtractor.CronPropertyDefaultName -> ScenarioPropertyConfig( + defaultValue = None, + editor = Some(StringParameterEditor), + validators = Some(List(MandatoryParameterValidator, CronParameterValidator)), + label = Some("Schedule"), + hintText = Some("Quartz cron syntax. You can specify multiple schedulers separated by '|'.") + ) + +} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessException.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessException.scala similarity index 74% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessException.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessException.scala index 72a267b3317..53257f71403 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessException.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessException.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic class PeriodicProcessException(message: String, parent: Throwable) extends RuntimeException(message, parent) { def this(message: String) = this(message, null) diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessService.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessService.scala similarity index 74% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessService.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessService.scala index 43c41264a8f..9569e7d4ade 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessService.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessService.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import cats.implicits._ import com.typesafe.scalalogging.LazyLogging @@ -10,29 +10,21 @@ import pl.touk.nussknacker.engine.api.component.{ } import pl.touk.nussknacker.engine.api.deployment.StateStatus.StatusName import pl.touk.nussknacker.engine.api.deployment._ +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{ScheduleProperty => _, _} +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{ScheduleProperty => ApiScheduleProperty} +import pl.touk.nussknacker.engine.api.deployment.scheduler.services._ import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus.ProblemStateStatus -import pl.touk.nussknacker.engine.api.process.{ProcessIdWithName, ProcessName} +import pl.touk.nussknacker.engine.api.process.{ProcessIdWithName, ProcessName, VersionId} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.engine.deployment.{AdditionalModelConfigs, DeploymentData, DeploymentId} -import pl.touk.nussknacker.engine.management.periodic.PeriodicProcessService.{ - DeploymentStatus, - EngineStatusesToReschedule, - FinishedScheduledExecutionMetadata, - MaxDeploymentsStatus, - PeriodicProcessStatus -} -import pl.touk.nussknacker.engine.management.periodic.PeriodicStateStatus.{ScheduledStatus, WaitingForScheduleStatus} -import pl.touk.nussknacker.engine.management.periodic.db.PeriodicProcessesRepository -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.{ - WithCanonicalProcess, - WithoutCanonicalProcess -} -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus -import pl.touk.nussknacker.engine.management.periodic.model._ -import pl.touk.nussknacker.engine.management.periodic.service._ -import pl.touk.nussknacker.engine.management.periodic.util.DeterministicUUIDFromLong import pl.touk.nussknacker.engine.util.AdditionalComponentConfigsForRuntimeExtractor +import pl.touk.nussknacker.ui.process.periodic.PeriodicProcessService._ +import pl.touk.nussknacker.ui.process.periodic.PeriodicStateStatus._ +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus +import pl.touk.nussknacker.ui.process.periodic.model._ +import pl.touk.nussknacker.ui.process.periodic.utils.DeterministicUUIDFromLong +import pl.touk.nussknacker.ui.process.repository.PeriodicProcessesRepository import java.time.chrono.ChronoLocalDateTime import java.time.temporal.ChronoUnit @@ -42,9 +34,9 @@ import scala.util.control.NonFatal class PeriodicProcessService( delegateDeploymentManager: DeploymentManager, - jarManager: JarManager, - scheduledProcessesRepository: PeriodicProcessesRepository, - periodicProcessListener: PeriodicProcessListener, + scheduledExecutionPerformer: ScheduledExecutionPerformer, + periodicProcessesRepository: PeriodicProcessesRepository, + periodicProcessListener: ScheduledProcessListener, additionalDeploymentDataProvider: AdditionalDeploymentDataProvider, deploymentRetryConfig: DeploymentRetryConfig, executionConfig: PeriodicExecutionConfig, @@ -52,19 +44,19 @@ class PeriodicProcessService( processConfigEnricher: ProcessConfigEnricher, clock: Clock, actionService: ProcessingTypeActionService, - configsFromProvider: Map[DesignerWideComponentId, ComponentAdditionalConfig] + configsFromProvider: Map[DesignerWideComponentId, ComponentAdditionalConfig], )(implicit ec: ExecutionContext) extends LazyLogging { import cats.syntax.all._ - import scheduledProcessesRepository._ - private type RepositoryAction[T] = scheduledProcessesRepository.Action[T] - private type Callback = () => Future[Unit] - private type NeedsReschedule = Boolean + import periodicProcessesRepository._ + + private type Callback = () => Future[Unit] + private type NeedsReschedule = Boolean - private implicit class WithCallbacksSeq(result: RepositoryAction[List[Callback]]) { + private implicit class WithCallbacksSeq(result: Future[List[Callback]]) { def runWithCallbacks: Future[Unit] = - result.run.flatMap(callbacks => Future.sequence(callbacks.map(_()))).map(_ => ()) + result.flatMap(callbacks => Future.sequence(callbacks.map(_()))).map(_ => ()) } private val emptyCallback: Callback = () => Future.successful(()) @@ -75,8 +67,11 @@ class PeriodicProcessService( processIdWithName: ProcessIdWithName, after: Option[Instant], ): Future[List[ScenarioActivity]] = for { - schedulesState <- scheduledProcessesRepository - .getSchedulesState(processIdWithName.name, after.map(localDateTimeAtSystemDefaultZone)) + schedulesState <- periodicProcessesRepository + .getSchedulesState( + processIdWithName.name, + after.map(localDateTimeAtSystemDefaultZone) + ) .run groupedByProcess = schedulesState.groupedByPeriodicProcess deployments = groupedByProcess.flatMap(_.deployments) @@ -91,7 +86,7 @@ class PeriodicProcessService( scenarioActivityId = ScenarioActivityId(DeterministicUUIDFromLong.longUUID(deployment.id.value)), user = ScenarioUser.internalNuUser, date = metadata.dateDeployed.getOrElse(metadata.dateFinished), - scenarioVersionId = Some(ScenarioVersionId.from(deployment.periodicProcess.processVersion.versionId)), + scenarioVersionId = Some(ScenarioVersionId.from(deployment.periodicProcess.deploymentData.versionId)), scheduledExecutionStatus = metadata.status, dateFinished = metadata.dateFinished, scheduleName = deployment.scheduleName.display, @@ -148,56 +143,70 @@ class PeriodicProcessService( processVersion: ProcessVersion, canonicalProcess: CanonicalProcess, scheduleDates: List[(ScheduleName, Option[LocalDateTime])], - processActionId: ProcessActionId + processActionId: ProcessActionId, ): Future[Unit] = { logger.info("Scheduling periodic scenario: {} on {}", processVersion, scheduleDates) + for { - deploymentWithJarData <- jarManager.prepareDeploymentWithJar(processVersion, canonicalProcess) + inputConfigDuringExecution <- scheduledExecutionPerformer.provideInputConfigDuringExecutionJson() + deploymentWithJarData <- scheduledExecutionPerformer.prepareDeploymentWithRuntimeParams( + processVersion, + ) enrichedProcessConfig <- processConfigEnricher.onInitialSchedule( - ProcessConfigEnricher.InitialScheduleData( - deploymentWithJarData.process, - deploymentWithJarData.inputConfigDuringExecutionJson - ) + ProcessConfigEnricher.InitialScheduleData(canonicalProcess, inputConfigDuringExecution.serialized) ) - enrichedDeploymentWithJarData = deploymentWithJarData.copy(inputConfigDuringExecutionJson = - enrichedProcessConfig.inputConfigDuringExecutionJson + _ <- initialSchedule( + scheduleProperty, + scheduleDates, + deploymentWithJarData, + canonicalProcess, + enrichedProcessConfig.inputConfigDuringExecutionJson, + processActionId, ) - _ <- initialSchedule(scheduleProperty, scheduleDates, enrichedDeploymentWithJarData, processActionId) } yield () } private def initialSchedule( scheduleMap: ScheduleProperty, scheduleDates: List[(ScheduleName, Option[LocalDateTime])], - deploymentWithJarData: DeploymentWithJarData.WithCanonicalProcess, - processActionId: ProcessActionId + deploymentWithJarData: DeploymentWithRuntimeParams, + canonicalProcess: CanonicalProcess, + inputConfigDuringExecutionJson: String, + processActionId: ProcessActionId, ): Future[Unit] = { - scheduledProcessesRepository - .create(deploymentWithJarData, scheduleMap, processActionId) + periodicProcessesRepository + .create( + deploymentWithJarData, + inputConfigDuringExecutionJson, + canonicalProcess, + scheduleMap, + processActionId + ) + .run .flatMap { process => scheduleDates.collect { case (name, Some(date)) => - scheduledProcessesRepository + periodicProcessesRepository .schedule(process.id, name, date, deploymentRetryConfig.deployMaxRetries) + .run .flatMap { data => - handleEvent(ScheduledEvent(data, firstSchedule = true)) + handleEvent(ScheduledEvent(data.toDetails, firstSchedule = true)) } case (name, None) => logger.warn(s"Schedule $name does not have date to schedule") - monad.pure(()) + Future.successful(()) }.sequence } - .run .map(_ => ()) } - def findToBeDeployed: Future[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]] = { + def findToBeDeployed: Future[Seq[PeriodicProcessDeployment]] = { for { - toBeDeployed <- scheduledProcessesRepository.findToBeDeployed.run.flatMap { toDeployList => + toBeDeployed <- periodicProcessesRepository.findToBeDeployed.run.flatMap { toDeployList => Future.sequence(toDeployList.map(checkIfNotRunning)).map(_.flatten) } // We retry scenarios that failed on deployment. Failure recovery of running scenarios should be handled by Flink's restart strategy - toBeRetried <- scheduledProcessesRepository.findToBeRetried.run + toBeRetried <- periodicProcessesRepository.findToBeRetried.run // We don't block scheduled deployments by retries } yield toBeDeployed.sortBy(d => (d.runAt, d.createdAt)) ++ toBeRetried.sortBy(d => (d.nextRetryAt, d.createdAt)) } @@ -205,10 +214,10 @@ class PeriodicProcessService( // Currently we don't allow simultaneous runs of one scenario - only sequential, so if other schedule kicks in, it'll have to wait // TODO: we show allow to deploy scenarios with different scheduleName to be deployed simultaneous private def checkIfNotRunning( - toDeploy: PeriodicProcessDeployment[WithCanonicalProcess] - ): Future[Option[PeriodicProcessDeployment[WithCanonicalProcess]]] = { + toDeploy: PeriodicProcessDeployment + ): Future[Option[PeriodicProcessDeployment]] = { delegateDeploymentManager - .getProcessStates(toDeploy.periodicProcess.processVersion.processName)(DataFreshnessPolicy.Fresh) + .getProcessStates(toDeploy.periodicProcess.deploymentData.processName)(DataFreshnessPolicy.Fresh) .map( _.value .map(_.status) @@ -228,7 +237,7 @@ class PeriodicProcessService( schedules.groupedByPeriodicProcess .collect { case processScheduleData - if processScheduleData.existsDeployment(d => needRescheduleDeploymentIds.contains(d.id)) => + if processScheduleData.deployments.exists(d => needRescheduleDeploymentIds.contains(d.id)) => reschedule(processScheduleData, needRescheduleDeploymentIds) } .sequence @@ -236,9 +245,9 @@ class PeriodicProcessService( } for { - schedules <- scheduledProcessesRepository + schedules <- periodicProcessesRepository .findActiveSchedulesForProcessesHavingDeploymentWithMatchingStatus( - Set(PeriodicProcessDeploymentStatus.Deployed, PeriodicProcessDeploymentStatus.FailedOnDeploy) + Set(PeriodicProcessDeploymentStatus.Deployed, PeriodicProcessDeploymentStatus.FailedOnDeploy), ) .run // we handle each job separately, if we fail at some point, we will continue on next handleFinished run @@ -261,32 +270,40 @@ class PeriodicProcessService( s"Process '$processName' latest deployment ids: ${scheduleData.latestDeployments.map(_.id.toString)}" ) scheduleData.latestDeployments.map { deployment => - (deployment, runtimeStatuses.getStatus(deployment.id)) + ( + scheduleData.process.deploymentData.processName, + scheduleData.process.deploymentData.versionId, + deployment, + runtimeStatuses.getStatus(deployment.id) + ) } } _ = logger.debug( s"Process '$processName' schedule deployments with status: ${scheduleDeploymentsWithStatus.map(_.toString)}" ) needRescheduleDeployments <- Future - .sequence(scheduleDeploymentsWithStatus.map { case (deploymentData, statusOpt) => - synchronizeDeploymentState(deploymentData, statusOpt).run.map { needReschedule => + .sequence(scheduleDeploymentsWithStatus.map { case (processName, versionId, deploymentData, statusOpt) => + synchronizeDeploymentState(processName, versionId, deploymentData, statusOpt).map { needReschedule => Option(deploymentData.id).filter(_ => needReschedule) } }) .map(_.flatten.toSet) followingDeployDeploymentsForSchedules = scheduleDeploymentsWithStatus.collect { - case (deployment, Some(status)) if SimpleStateStatus.DefaultFollowingDeployStatuses.contains(status.status) => + case (_, _, deployment, Some(status)) + if SimpleStateStatus.DefaultFollowingDeployStatuses.contains(status.status) => deployment.id }.toSet } yield (followingDeployDeploymentsForSchedules, needRescheduleDeployments) // We assume that this method leaves with data in consistent state private def synchronizeDeploymentState( + processName: ProcessName, + versionId: VersionId, deployment: ScheduleDeploymentData, - processState: Option[StatusDetails] - ): RepositoryAction[NeedsReschedule] = { - implicit class RichRepositoryAction[Unit](a: RepositoryAction[Unit]) { - def needsReschedule(value: Boolean): RepositoryAction[NeedsReschedule] = a.map(_ => value) + processState: Option[StatusDetails], + ): Future[NeedsReschedule] = { + implicit class RichFuture[Unit](a: Future[Unit]) { + def needsReschedule(value: Boolean): Future[NeedsReschedule] = a.map(_ => value) } processState.map(_.status) match { case Some(status) @@ -298,7 +315,7 @@ class PeriodicProcessService( if EngineStatusesToReschedule.contains( status ) && deployment.state.status != PeriodicProcessDeploymentStatus.Finished => - markFinished(deployment, processState).needsReschedule(value = true) + markFinished(processName, versionId, deployment, processState).needsReschedule(value = true) case None if deployment.state.status == PeriodicProcessDeploymentStatus.Deployed && deployment.deployedAt.exists(_.isBefore(LocalDateTime.now().minusMinutes(5))) => @@ -306,26 +323,27 @@ class PeriodicProcessService( // this can be caused by a race in e.g. FlinkRestManager // (because /jobs/overview used in getProcessStates isn't instantly aware of submitted jobs) // so freshly deployed deployments aren't considered - markFinished(deployment, processState).needsReschedule(value = true) + markFinished(processName, versionId, deployment, processState).needsReschedule(value = true) case _ => - scheduledProcessesRepository.monad.pure(()).needsReschedule(value = false) + Future.successful(()).needsReschedule(value = false) } } private def reschedule( processScheduleData: PeriodicProcessScheduleData, needRescheduleDeploymentIds: Set[PeriodicProcessDeploymentId] - ): RepositoryAction[Callback] = { + ): Future[Callback] = { import processScheduleData._ val scheduleActions = deployments.map { deployment => if (needRescheduleDeploymentIds.contains(deployment.id)) - deployment.nextRunAt(clock) match { + nextRunAt(deployment, clock) match { case Right(Some(futureDate)) => logger.info(s"Rescheduling ${deployment.display} to $futureDate") - val action = scheduledProcessesRepository + val action = periodicProcessesRepository .schedule(process.id, deployment.scheduleName, futureDate, deploymentRetryConfig.deployMaxRetries) + .run .flatMap { data => - handleEvent(ScheduledEvent(data, firstSchedule = false)) + handleEvent(ScheduledEvent(data.toDetails, firstSchedule = false)) } Some(action) case Right(None) => @@ -338,7 +356,7 @@ class PeriodicProcessService( else Option(deployment) .filter(_.state.status == PeriodicProcessDeploymentStatus.Scheduled) - .map(_ => scheduledProcessesRepository.monad.pure(())) + .map(_ => Future.successful(())) } @@ -352,18 +370,45 @@ class PeriodicProcessService( scheduleActions.flatten.sequence.as(emptyCallback) } - private def markFinished(deployment: ScheduleDeploymentData, state: Option[StatusDetails]): RepositoryAction[Unit] = { + private def nextRunAt( + deployment: PeriodicProcessDeployment, + clock: Clock + ): Either[String, Option[LocalDateTime]] = + (deployment.periodicProcess.scheduleProperty, deployment.scheduleName.value) match { + case (MultipleScheduleProperty(schedules), Some(name)) => + schedules.get(name).toRight(s"Failed to find schedule: $deployment.scheduleName").flatMap(_.nextRunAt(clock)) + case (e: SingleScheduleProperty, None) => e.nextRunAt(clock) + case (schedule, name) => Left(s"Schedule name: $name mismatch with schedule: $schedule") + } + + private def markFinished( + processName: ProcessName, + versionId: VersionId, + deployment: ScheduleDeploymentData, + state: Option[StatusDetails], + ): Future[Unit] = { logger.info(s"Marking ${deployment.display} with status: ${deployment.state.status} as finished") for { - _ <- scheduledProcessesRepository.markFinished(deployment.id) - currentState <- scheduledProcessesRepository.findProcessData(deployment.id) - } yield handleEvent(FinishedEvent(currentState, state)) + _ <- periodicProcessesRepository.markFinished(deployment.id).run + currentState <- periodicProcessesRepository.findProcessData(deployment.id).run + canonicalProcessOpt <- periodicProcessesRepository + .fetchCanonicalProcessWithVersion( + processName, + versionId + ) + .map(_.map(_._1)) + canonicalProcess = canonicalProcessOpt.getOrElse { + throw new PeriodicProcessException( + s"Could not fetch CanonicalProcess with ProcessVersion for processName=$processName, versionId=$versionId" + ) + } + } yield handleEvent(FinishedEvent(currentState.toDetails, canonicalProcess, state)) } private def handleFailedDeployment( - deployment: PeriodicProcessDeployment[_], + deployment: PeriodicProcessDeployment, state: Option[StatusDetails] - ): RepositoryAction[Unit] = { + ): Future[Unit] = { def calculateNextRetryAt = now().plus(deploymentRetryConfig.deployRetryPenalize.toMillis, ChronoUnit.MILLIS) val retriesLeft = @@ -382,20 +427,20 @@ class PeriodicProcessService( ) for { - _ <- scheduledProcessesRepository.markFailedOnDeployWithStatus(deployment.id, status, retriesLeft, nextRetryAt) - currentState <- scheduledProcessesRepository.findProcessData(deployment.id) - } yield handleEvent(FailedOnDeployEvent(currentState, state)) + _ <- periodicProcessesRepository.markFailedOnDeployWithStatus(deployment.id, status, retriesLeft, nextRetryAt).run + currentState <- periodicProcessesRepository.findProcessData(deployment.id).run + } yield handleEvent(FailedOnDeployEvent(currentState.toDetails, state)) } private def markFailedAction( deployment: ScheduleDeploymentData, state: Option[StatusDetails] - ): RepositoryAction[Unit] = { + ): Future[Unit] = { logger.info(s"Marking ${deployment.display} as failed.") for { - _ <- scheduledProcessesRepository.markFailed(deployment.id) - currentState <- scheduledProcessesRepository.findProcessData(deployment.id) - } yield handleEvent(FailedOnRunEvent(currentState, state)) + _ <- periodicProcessesRepository.markFailed(deployment.id).run + currentState <- periodicProcessesRepository.findProcessData(deployment.id).run + } yield handleEvent(FailedOnRunEvent(currentState.toDetails, state)) } def deactivate(processName: ProcessName): Future[Iterable[DeploymentId]] = @@ -405,32 +450,34 @@ class PeriodicProcessService( _ <- activeSchedules.groupedByPeriodicProcess.map(p => deactivateAction(p.process)).sequence.runWithCallbacks } yield runningDeploymentsForSchedules.map(deployment => DeploymentId(deployment.toString)) - private def deactivateAction(process: PeriodicProcess[WithoutCanonicalProcess]): RepositoryAction[Callback] = { + private def deactivateAction( + process: PeriodicProcess + ): Future[Callback] = { logger.info(s"Deactivate periodic process id: ${process.id.value}") for { - _ <- scheduledProcessesRepository.markInactive(process.id) + _ <- periodicProcessesRepository.markInactive(process.id).run // we want to delete jars only after we successfully mark process as inactive. It's better to leave jar garbage than // have process without jar - } yield () => jarManager.deleteJar(process.deploymentData.jarFileName) + } yield () => scheduledExecutionPerformer.cleanAfterDeployment(process.deploymentData.runtimeParams) } private def markProcessActionExecutionFinished( processActionIdOption: Option[ProcessActionId] - ): RepositoryAction[Callback] = - scheduledProcessesRepository.monad.pure { () => + ): Future[Callback] = + Future.successful { () => processActionIdOption .map(actionService.markActionExecutionFinished) .sequence .map(_ => ()) } - def deploy(deployment: PeriodicProcessDeployment[WithCanonicalProcess]): Future[Unit] = { + def deploy(deployment: PeriodicProcessDeployment): Future[Unit] = { // TODO: set status before deployment? val id = deployment.id val deploymentData = DeploymentData( DeploymentId(id.toString), DeploymentData.systemUser, - additionalDeploymentDataProvider.prepareAdditionalData(deployment), + additionalDeploymentDataProvider.prepareAdditionalData(deployment.toDetails), // TODO: in the future we could allow users to specify nodes data during schedule requesting NodesDeploymentData.empty, AdditionalModelConfigs( @@ -440,42 +487,69 @@ class PeriodicProcessService( val deploymentWithJarData = deployment.periodicProcess.deploymentData val deploymentAction = for { _ <- Future.successful( - logger.info("Deploying scenario {} for deployment id {}", deploymentWithJarData.processVersion, id) + logger.info("Deploying scenario {} for deployment id {}", deploymentWithJarData, id) ) + processName = deploymentWithJarData.processName + versionId = deploymentWithJarData.versionId + canonicalProcessWithVersionOpt <- periodicProcessesRepository + .fetchCanonicalProcessWithVersion( + processName, + versionId + ) + canonicalProcessWithVersion = canonicalProcessWithVersionOpt.getOrElse { + throw new PeriodicProcessException( + s"Could not fetch CanonicalProcess with ProcessVersion for processName=$processName, versionId=$versionId" + ) + } + inputConfigDuringExecutionJsonOpt <- periodicProcessesRepository + .fetchInputConfigDuringExecutionJson( + processName, + versionId, + ) + .run + inputConfigDuringExecutionJson = inputConfigDuringExecutionJsonOpt.getOrElse { + throw new PeriodicProcessException( + s"Could not fetch inputConfigDuringExecutionJson for processName=${processName}, versionId=${versionId}" + ) + } enrichedProcessConfig <- processConfigEnricher.onDeploy( ProcessConfigEnricher.DeployData( - deploymentWithJarData.process, - deploymentWithJarData.inputConfigDuringExecutionJson, - deployment + canonicalProcessWithVersion._1, + canonicalProcessWithVersion._2, + inputConfigDuringExecutionJson, + deployment.toDetails ) ) - enrichedDeploymentWithJarData = deploymentWithJarData.copy(inputConfigDuringExecutionJson = - enrichedProcessConfig.inputConfigDuringExecutionJson + externalDeploymentId <- scheduledExecutionPerformer.deployWithRuntimeParams( + deploymentWithJarData, + enrichedProcessConfig.inputConfigDuringExecutionJson, + deploymentData, + canonicalProcessWithVersion._1, + canonicalProcessWithVersion._2, ) - externalDeploymentId <- jarManager.deployWithJar(enrichedDeploymentWithJarData, deploymentData) } yield externalDeploymentId deploymentAction .flatMap { externalDeploymentId => - logger.info("Scenario has been deployed {} for deployment id {}", deploymentWithJarData.processVersion, id) + logger.info("Scenario has been deployed {} for deployment id {}", deploymentWithJarData, id) // TODO: add externalDeploymentId?? - scheduledProcessesRepository + periodicProcessesRepository .markDeployed(id) - .flatMap(_ => scheduledProcessesRepository.findProcessData(id)) - .flatMap(afterChange => handleEvent(DeployedEvent(afterChange, externalDeploymentId))) .run + .flatMap(_ => periodicProcessesRepository.findProcessData(id).run) + .flatMap(afterChange => handleEvent(DeployedEvent(afterChange.toDetails, externalDeploymentId))) } // We can recover since deployment actor watches only future completion. .recoverWith { case exception => logger.error(s"Scenario deployment ${deployment.display} failed", exception) - handleFailedDeployment(deployment, None).run + handleFailedDeployment(deployment, None) } } // TODO: allow access to DB in listener? - private def handleEvent(event: PeriodicProcessEvent): scheduledProcessesRepository.Action[Unit] = { - scheduledProcessesRepository.monad.pure { + private def handleEvent(event: ScheduledProcessEvent): Future[Unit] = { + Future.successful { try { - periodicProcessListener.onPeriodicProcessEvent.applyOrElse(event, (_: PeriodicProcessEvent) => ()) + periodicProcessListener.onScheduledProcessEvent.applyOrElse(event, (_: ScheduledProcessEvent) => ()) } catch { case NonFatal(e) => throw new PeriodicProcessException("Failed to invoke listener", e) } @@ -523,7 +597,7 @@ class PeriodicProcessService( def toDeploymentStatuses(schedulesState: SchedulesState) = schedulesState.schedules.toList .flatMap { case (scheduleId, scheduleData) => scheduleData.latestDeployments.map { deployment => - DeploymentStatus( + PeriodicDeploymentStatus( deployment.id, scheduleId, deployment.createdAt, @@ -534,7 +608,7 @@ class PeriodicProcessService( ) } } - .sorted(DeploymentStatus.ordering.reverse) + .sorted(PeriodicDeploymentStatus.ordering.reverse) for { activeSchedules <- getLatestDeploymentsForActiveSchedules(name, MaxDeploymentsStatus) @@ -556,7 +630,7 @@ class PeriodicProcessService( schedulesState.schedules.toList .flatMap { case (scheduleId, scheduleData) => scheduleData.latestDeployments.map { deployment => - DeploymentStatus( + PeriodicDeploymentStatus( deployment.id, scheduleId, deployment.createdAt, @@ -567,7 +641,7 @@ class PeriodicProcessService( ) } } - .sorted(DeploymentStatus.ordering.reverse) + .sorted(PeriodicDeploymentStatus.ordering.reverse) for { activeSchedules <- getLatestDeploymentsForActiveSchedules(MaxDeploymentsStatus) @@ -591,23 +665,28 @@ class PeriodicProcessService( processName: ProcessName, deploymentsPerScheduleMaxCount: Int = 1 ): Future[SchedulesState] = - scheduledProcessesRepository.getLatestDeploymentsForActiveSchedules(processName, deploymentsPerScheduleMaxCount).run + periodicProcessesRepository + .getLatestDeploymentsForActiveSchedules( + processName, + deploymentsPerScheduleMaxCount, + ) + .run def getLatestDeploymentsForActiveSchedules( deploymentsPerScheduleMaxCount: Int ): Future[Map[ProcessName, SchedulesState]] = - scheduledProcessesRepository.getLatestDeploymentsForActiveSchedules(deploymentsPerScheduleMaxCount).run + periodicProcessesRepository.getLatestDeploymentsForActiveSchedules(deploymentsPerScheduleMaxCount).run def getLatestDeploymentsForLatestInactiveSchedules( processName: ProcessName, inactiveProcessesMaxCount: Int, deploymentsPerScheduleMaxCount: Int ): Future[SchedulesState] = - scheduledProcessesRepository + periodicProcessesRepository .getLatestDeploymentsForLatestInactiveSchedules( processName, inactiveProcessesMaxCount, - deploymentsPerScheduleMaxCount + deploymentsPerScheduleMaxCount, ) .run @@ -615,7 +694,7 @@ class PeriodicProcessService( inactiveProcessesMaxCount: Int, deploymentsPerScheduleMaxCount: Int ): Future[Map[ProcessName, SchedulesState]] = - scheduledProcessesRepository + periodicProcessesRepository .getLatestDeploymentsForLatestInactiveSchedules( inactiveProcessesMaxCount, deploymentsPerScheduleMaxCount @@ -631,7 +710,7 @@ class PeriodicProcessService( } private def scheduledExecutionStatusAndDateFinished( - entity: PeriodicProcessDeployment[WithoutCanonicalProcess], + entity: PeriodicProcessDeployment, ): Option[FinishedScheduledExecutionMetadata] = { for { status <- entity.state.status match { @@ -688,15 +767,15 @@ object PeriodicProcessService { // of single, merged status similar to this available for streaming job. This merged status should be a straightforward derivative // of these deployments statuses so it will be easy to figure out it by user. case class PeriodicProcessStatus( - activeDeploymentsStatuses: List[DeploymentStatus], - inactiveDeploymentsStatuses: List[DeploymentStatus] + activeDeploymentsStatuses: List[PeriodicDeploymentStatus], + inactiveDeploymentsStatuses: List[PeriodicDeploymentStatus] ) extends StateStatus with LazyLogging { - def limitedAndSortedDeployments: List[DeploymentStatus] = + def limitedAndSortedDeployments: List[PeriodicDeploymentStatus] = (activeDeploymentsStatuses ++ inactiveDeploymentsStatuses.take( MaxDeploymentsStatus - activeDeploymentsStatuses.size - )).sorted(DeploymentStatus.ordering.reverse) + )).sorted(PeriodicDeploymentStatus.ordering.reverse) // We present merged name to be possible to filter scenario by status override def name: StatusName = mergedStatusDetails.status.name @@ -761,7 +840,7 @@ object PeriodicProcessService { * should be deactivated earlier. * */ - def pickMostImportantActiveDeployment: Option[DeploymentStatus] = { + def pickMostImportantActiveDeployment: Option[PeriodicDeploymentStatus] = { val lastActiveDeploymentStatusForEachSchedule = latestDeploymentForEachSchedule(activeDeploymentsStatuses).sorted @@ -779,17 +858,17 @@ object PeriodicProcessService { .orElse(last(PeriodicProcessDeploymentStatus.Finished)) } - private def latestDeploymentForEachSchedule(deploymentsStatuses: List[DeploymentStatus]) = { + private def latestDeploymentForEachSchedule(deploymentsStatuses: List[PeriodicDeploymentStatus]) = { deploymentsStatuses .groupBy(_.scheduleId) .values .toList - .map(_.min(DeploymentStatus.ordering.reverse)) + .map(_.min(PeriodicDeploymentStatus.ordering.reverse)) } } - case class DeploymentStatus( // Probably it is too much technical to present to users, but the only other alternative + case class PeriodicDeploymentStatus( // Probably it is too much technical to present to users, but the only other alternative // to present to users is scheduleName+runAt deploymentId: PeriodicProcessDeploymentId, scheduleId: ScheduleId, @@ -820,14 +899,15 @@ object PeriodicProcessService { } - object DeploymentStatus { + object PeriodicDeploymentStatus { - implicit val ordering: Ordering[DeploymentStatus] = (self: DeploymentStatus, that: DeploymentStatus) => { - self.runAt.compareTo(that.runAt) match { - case 0 => self.createdAt.compareTo(that.createdAt) - case a => a + implicit val ordering: Ordering[PeriodicDeploymentStatus] = + (self: PeriodicDeploymentStatus, that: PeriodicDeploymentStatus) => { + self.runAt.compareTo(that.runAt) match { + case 0 => self.createdAt.compareTo(that.createdAt) + case a => a + } } - } } diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessStateDefinitionManager.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessStateDefinitionManager.scala similarity index 80% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessStateDefinitionManager.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessStateDefinitionManager.scala index 008aabcca05..17c3642a8c8 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessStateDefinitionManager.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessStateDefinitionManager.scala @@ -1,13 +1,8 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import pl.touk.nussknacker.engine.api.deployment.ProcessStateDefinitionManager.defaultVisibleActions -import pl.touk.nussknacker.engine.api.deployment.{ - OverridingProcessStateDefinitionManager, - ProcessStateDefinitionManager, - ScenarioActionName, - StateStatus -} -import pl.touk.nussknacker.engine.management.periodic.PeriodicProcessService.{DeploymentStatus, PeriodicProcessStatus} +import pl.touk.nussknacker.engine.api.deployment.{OverridingProcessStateDefinitionManager, ProcessStateDefinitionManager, ScenarioActionName, StateStatus} +import pl.touk.nussknacker.ui.process.periodic.PeriodicProcessService.{PeriodicDeploymentStatus, PeriodicProcessStatus} class PeriodicProcessStateDefinitionManager(delegate: ProcessStateDefinitionManager) extends OverridingProcessStateDefinitionManager( @@ -33,7 +28,7 @@ object PeriodicProcessStateDefinitionManager { def statusTooltip(processStatus: PeriodicProcessStatus): String = { processStatus.limitedAndSortedDeployments - .map { case d @ DeploymentStatus(_, scheduleId, _, runAt, status, _, _) => + .map { case d @ PeriodicDeploymentStatus(_, scheduleId, _, runAt, status, _, _) => val refinedStatus = { if (d.isCanceled) { "Canceled" diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicStateStatus.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicStateStatus.scala similarity index 98% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicStateStatus.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicStateStatus.scala index a90c0cee3fb..ce47c08485d 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicStateStatus.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicStateStatus.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import pl.touk.nussknacker.engine.api.deployment.ProcessStateDefinitionManager.ProcessStatus import pl.touk.nussknacker.engine.api.deployment.StateStatus.StatusName diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/RescheduleFinishedActor.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/RescheduleFinishedActor.scala similarity index 90% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/RescheduleFinishedActor.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/RescheduleFinishedActor.scala index d53b95c95c0..33d240de627 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/RescheduleFinishedActor.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/RescheduleFinishedActor.scala @@ -1,8 +1,8 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import akka.actor.{Actor, Props, Timers} import com.typesafe.scalalogging.LazyLogging -import pl.touk.nussknacker.engine.management.periodic.RescheduleFinishedActor.{CheckStates, CheckStatesCompleted} +import pl.touk.nussknacker.ui.process.periodic.RescheduleFinishedActor.{CheckStates, CheckStatesCompleted} import scala.concurrent.Future import scala.concurrent.duration._ diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SingleScheduleProperty.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/ScheduleProperty.scala similarity index 95% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SingleScheduleProperty.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/ScheduleProperty.scala index 97b7aa80af9..86f01f37130 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SingleScheduleProperty.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/ScheduleProperty.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import com.cronutils.model.definition.CronDefinitionBuilder import com.cronutils.model.time.ExecutionTime @@ -34,7 +34,7 @@ object SingleScheduleProperty { @JsonCodec case class CronScheduleProperty(labelOrCronExpr: String) extends SingleScheduleProperty { import cats.implicits._ - import pl.touk.nussknacker.engine.management.periodic.CronScheduleProperty._ + import pl.touk.nussknacker.ui.process.periodic.CronScheduleProperty._ private lazy val cronsOrError: Either[String, List[Cron]] = { val (errors, crons) = labelOrCronExpr diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicBatchConfig.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/SchedulingConfig.scala similarity index 71% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicBatchConfig.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/SchedulingConfig.scala index 61b8f9bb1ac..d8b6f8df610 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicBatchConfig.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/SchedulingConfig.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import com.typesafe.config.Config @@ -7,22 +7,23 @@ import scala.concurrent.duration._ /** * Periodic Flink scenarios deployment configuration. * - * @param db Nussknacker db configuration. + * @param legacyDb Optional custom db, that will be used instead of main Nussknacker DB. Will be removed in the future. * @param processingType processing type of scenarios to be managed by this instance of the periodic engine. * @param rescheduleCheckInterval {@link RescheduleFinishedActor} check interval. * @param deployInterval {@link DeploymentActor} check interval. * @param deploymentRetry {@link DeploymentRetryConfig} for deployment failure recovery. - * @param jarsDir Directory for jars storage. * @param maxFetchedPeriodicScenarioActivities Optional limit of number of latest periodic-related Scenario Activities that are returned by Periodic DM. */ -case class PeriodicBatchConfig( - db: Config, +case class SchedulingConfig( + legacyDb: Option[Config], + // The `processingType` value should be removed in the future, because it should correspond to the real value of processingType. + // But at the moment it may not be equal to the value of processingType of the DM that uses scheduling mechanism. + // Therefore, we must keep the separate value SchedulingConfig, until we ensure consistency between the real processingType and the one defined here. processingType: String, rescheduleCheckInterval: FiniteDuration = 13 seconds, deployInterval: FiniteDuration = 17 seconds, deploymentRetry: DeploymentRetryConfig, executionConfig: PeriodicExecutionConfig, - jarsDir: String, maxFetchedPeriodicScenarioActivities: Option[Int] = Some(200), ) diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/Utils.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/Utils.scala similarity index 95% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/Utils.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/Utils.scala index b3fe622b411..a3ba8073295 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/Utils.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/Utils.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import akka.actor.{ActorRef, ActorSystem, Props} import com.typesafe.scalalogging.LazyLogging diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/cron/CronParameterValidator.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/cron/CronParameterValidator.scala similarity index 63% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/cron/CronParameterValidator.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/cron/CronParameterValidator.scala index 9eddcdbde26..18f11368259 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/cron/CronParameterValidator.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/cron/CronParameterValidator.scala @@ -1,27 +1,17 @@ -package pl.touk.nussknacker.engine.management.periodic.cron +package pl.touk.nussknacker.ui.process.periodic.cron import cats.data.Validated import cats.data.Validated.{invalid, valid} import pl.touk.nussknacker.engine.api import pl.touk.nussknacker.engine.api.context.PartSubGraphCompilationError import pl.touk.nussknacker.engine.api.context.ProcessCompilationError.CustomParameterValidationError -import pl.touk.nussknacker.engine.api.definition.{ - CustomParameterValidator, - CustomParameterValidatorDelegate, - ParameterValidator -} +import pl.touk.nussknacker.engine.api.definition.CustomParameterValidatorDelegate import pl.touk.nussknacker.engine.api.parameter.ParameterName import pl.touk.nussknacker.engine.graph.expression.Expression -import pl.touk.nussknacker.engine.management.periodic.SchedulePropertyExtractor - -object CronParameterValidator extends CronParameterValidator { - - def delegate: ParameterValidator = CustomParameterValidatorDelegate(name) - -} +import pl.touk.nussknacker.ui.process.periodic.utils.SchedulePropertyExtractorUtils // Valid expression is e.g.: 0 * * * * ? * which means run every minute at 0 second -class CronParameterValidator extends CustomParameterValidator { +object CronParameterValidator extends CustomParameterValidatorDelegate("cron_validator") { override def isValid(paramName: ParameterName, expression: Expression, value: Option[Any], label: Option[String])( implicit nodeId: api.NodeId @@ -36,12 +26,12 @@ class CronParameterValidator extends CustomParameterValidator { } value match { case Some(s: String) => - SchedulePropertyExtractor.parseAndValidateProperty(s).fold(_ => invalid(createValidationError), _ => valid(())) + SchedulePropertyExtractorUtils + .parseAndValidateProperty(s) + .fold(_ => invalid(createValidationError), _ => valid(())) case _ => invalid(createValidationError) } } - override def name: String = "cron_validator" - } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/cron/CronSchedulePropertyExtractor.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/cron/CronSchedulePropertyExtractor.scala new file mode 100644 index 00000000000..96294b9a08a --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/cron/CronSchedulePropertyExtractor.scala @@ -0,0 +1,34 @@ +package pl.touk.nussknacker.ui.process.periodic.cron + +import com.typesafe.scalalogging.LazyLogging +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{ScheduleProperty => ApiScheduleProperty} +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.SchedulePropertyExtractor +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.ui.process.periodic.cron.CronSchedulePropertyExtractor.CronPropertyDefaultName +import pl.touk.nussknacker.ui.process.periodic.utils.SchedulePropertyExtractorUtils +import pl.touk.nussknacker.ui.process.periodic.{CronScheduleProperty, MultipleScheduleProperty, SingleScheduleProperty} + +object CronSchedulePropertyExtractor { + val CronPropertyDefaultName = "cron" +} + +case class CronSchedulePropertyExtractor(propertyName: String = CronPropertyDefaultName) + extends SchedulePropertyExtractor + with LazyLogging { + + override def apply(canonicalProcess: CanonicalProcess): Either[String, ApiScheduleProperty] = { + SchedulePropertyExtractorUtils.extractProperty(canonicalProcess, propertyName).map { + case MultipleScheduleProperty(schedules) => + ApiScheduleProperty.MultipleScheduleProperty(schedules.map { case (k, v) => (k, toApi(v)) }) + case cronProperty: CronScheduleProperty => + toApi(cronProperty) + } + } + + private def toApi(singleProperty: SingleScheduleProperty): ApiScheduleProperty.SingleScheduleProperty = { + singleProperty match { + case CronScheduleProperty(labelOrCronExpr) => ApiScheduleProperty.CronScheduleProperty(labelOrCronExpr) + } + } + +} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/DbInitializer.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyDbInitializer.scala similarity index 95% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/DbInitializer.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyDbInitializer.scala index bb65cef5794..0dbaa7fd6a3 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/DbInitializer.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyDbInitializer.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic.db +package pl.touk.nussknacker.ui.process.periodic.legacy.db import com.github.tminglei.slickpg.ExPostgresProfile import com.typesafe.config.Config @@ -9,7 +9,7 @@ import org.flywaydb.core.api.configuration.FluentConfiguration import org.flywaydb.core.internal.database.postgresql.PostgreSQLDatabaseType import slick.jdbc.{HsqldbProfile, JdbcBackend, JdbcProfile, PostgresProfile} -object DbInitializer extends LazyLogging { +object LegacyDbInitializer extends LazyLogging { def init(configDb: Config): (JdbcBackend.DatabaseDef, JdbcProfile) = { import net.ceedubs.ficus.Ficus._ diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessDeploymentsTableFactory.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessDeploymentsTableFactory.scala new file mode 100644 index 00000000000..7518b5cda0a --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessDeploymentsTableFactory.scala @@ -0,0 +1,76 @@ +package pl.touk.nussknacker.ui.process.periodic.legacy.db + +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus +import pl.touk.nussknacker.ui.process.periodic.model.{PeriodicProcessDeploymentId, PeriodicProcessDeploymentStatus, PeriodicProcessId} +import slick.jdbc.{JdbcProfile, JdbcType} +import slick.lifted.ProvenShape +import slick.sql.SqlProfile.ColumnOption.NotNull + +import java.time.LocalDateTime + +trait LegacyPeriodicProcessDeploymentsTableFactory extends LegacyPeriodicProcessesTableFactory { + + protected val profile: JdbcProfile + + import profile.api._ + + implicit val periodicProcessDeploymentIdMapping: BaseColumnType[PeriodicProcessDeploymentId] = + MappedColumnType.base[PeriodicProcessDeploymentId, Long](_.value, PeriodicProcessDeploymentId.apply) + + implicit val periodicProcessDeploymentStatusColumnTyped: JdbcType[PeriodicProcessDeploymentStatus] = + MappedColumnType.base[PeriodicProcessDeploymentStatus, String](_.toString, PeriodicProcessDeploymentStatus.withName) + + class PeriodicProcessDeploymentsTable(tag: Tag) + extends Table[PeriodicProcessDeploymentEntity](tag, "periodic_process_deployments") { + + def id: Rep[PeriodicProcessDeploymentId] = column[PeriodicProcessDeploymentId]("id", O.PrimaryKey, O.AutoInc) + + def periodicProcessId: Rep[PeriodicProcessId] = column[PeriodicProcessId]("periodic_process_id", NotNull) + + def createdAt: Rep[LocalDateTime] = column[LocalDateTime]("created_at", NotNull) + + def runAt: Rep[LocalDateTime] = column[LocalDateTime]("run_at", NotNull) + + def scheduleName: Rep[Option[String]] = column[Option[String]]("schedule_name") + + def deployedAt: Rep[Option[LocalDateTime]] = column[Option[LocalDateTime]]("deployed_at") + + def completedAt: Rep[Option[LocalDateTime]] = column[Option[LocalDateTime]]("completed_at") + + def retriesLeft: Rep[Int] = column[Int]("retries_left") + + def nextRetryAt: Rep[Option[LocalDateTime]] = column[Option[LocalDateTime]]("next_retry_at") + + def status: Rep[PeriodicProcessDeploymentStatus] = column[PeriodicProcessDeploymentStatus]("status", NotNull) + + override def * : ProvenShape[PeriodicProcessDeploymentEntity] = ( + id, + periodicProcessId, + createdAt, + runAt, + scheduleName, + deployedAt, + completedAt, + retriesLeft, + nextRetryAt, + status + ) <> + ((PeriodicProcessDeploymentEntity.apply _).tupled, PeriodicProcessDeploymentEntity.unapply) + + } + + object PeriodicProcessDeployments extends TableQuery(new PeriodicProcessDeploymentsTable(_)) +} + +case class PeriodicProcessDeploymentEntity( + id: PeriodicProcessDeploymentId, + periodicProcessId: PeriodicProcessId, + createdAt: LocalDateTime, + runAt: LocalDateTime, + scheduleName: Option[String], + deployedAt: Option[LocalDateTime], + completedAt: Option[LocalDateTime], + retriesLeft: Int, + nextRetryAt: Option[LocalDateTime], + status: PeriodicProcessDeploymentStatus +) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessesRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessesRepository.scala new file mode 100644 index 00000000000..d747be320b2 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessesRepository.scala @@ -0,0 +1,498 @@ +package pl.touk.nussknacker.ui.process.periodic.legacy.db + +import cats.Monad +import com.github.tminglei.slickpg.ExPostgresProfile +import com.typesafe.scalalogging.LazyLogging +import db.util.DBIOActionInstances.DB +import io.circe.parser.decode +import io.circe.syntax.EncoderOps +import pl.touk.nussknacker.engine.api.ProcessVersion +import pl.touk.nussknacker.engine.api.deployment.ProcessActionId +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{DeploymentWithRuntimeParams, RuntimeParams} +import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId} +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.engine.management.FlinkScheduledExecutionPerformer.jarFileNameRuntimeParam +import pl.touk.nussknacker.ui.process.periodic.ScheduleProperty +import pl.touk.nussknacker.ui.process.periodic.legacy.db.LegacyPeriodicProcessesRepository.createPeriodicProcess +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus +import pl.touk.nussknacker.ui.process.periodic.model._ +import pl.touk.nussknacker.ui.process.repository.{FetchingProcessRepository, PeriodicProcessesRepository} +import pl.touk.nussknacker.ui.security.api.NussknackerInternalUser +import slick.dbio.{DBIOAction, Effect, NoStream} +import slick.jdbc.PostgresProfile.api._ +import slick.jdbc.{JdbcBackend, JdbcProfile} + +import java.time.{Clock, LocalDateTime} +import scala.concurrent.{ExecutionContext, Future} +import scala.language.higherKinds + +object LegacyPeriodicProcessesRepository { + + def createPeriodicProcessDeployment( + processEntity: PeriodicProcessEntity, + processDeploymentEntity: PeriodicProcessDeploymentEntity + ): PeriodicProcessDeployment = { + val process = createPeriodicProcess(processEntity) + PeriodicProcessDeployment( + processDeploymentEntity.id, + process, + processDeploymentEntity.createdAt, + processDeploymentEntity.runAt, + ScheduleName(processDeploymentEntity.scheduleName), + processDeploymentEntity.retriesLeft, + processDeploymentEntity.nextRetryAt, + createPeriodicDeploymentState(processDeploymentEntity) + ) + } + + def createPeriodicDeploymentState( + processDeploymentEntity: PeriodicProcessDeploymentEntity + ): PeriodicProcessDeploymentState = { + PeriodicProcessDeploymentState( + processDeploymentEntity.deployedAt, + processDeploymentEntity.completedAt, + processDeploymentEntity.status + ) + } + + def createPeriodicProcess( + processEntity: PeriodicProcessEntity + ): PeriodicProcess = { + val scheduleProperty = prepareScheduleProperty(processEntity) + PeriodicProcess( + processEntity.id, + DeploymentWithRuntimeParams( + processId = None, + processName = processEntity.processName, + versionId = processEntity.processVersionId, + runtimeParams = RuntimeParams(Map(jarFileNameRuntimeParam -> processEntity.jarFileName)), + ), + scheduleProperty, + processEntity.active, + processEntity.createdAt, + processEntity.processActionId + ) + } + + private def prepareScheduleProperty(processEntity: PeriodicProcessEntity) = { + val scheduleProperty = decode[ScheduleProperty](processEntity.scheduleProperty) + .fold(e => throw new IllegalArgumentException(e), identity) + scheduleProperty + } + +} + +class SlickLegacyPeriodicProcessesRepository( + processingType: String, + db: JdbcBackend.DatabaseDef, + override val profile: JdbcProfile, + clock: Clock, + fetchingProcessRepository: FetchingProcessRepository[Future], +)(implicit ec: ExecutionContext) + extends PeriodicProcessesRepository + with LegacyPeriodicProcessesTableFactory + with LegacyPeriodicProcessDeploymentsTableFactory + with LazyLogging { + + import pl.touk.nussknacker.engine.util.Implicits._ + + type Action[T] = DBIOActionInstances.DB[T] + + override def run[T](action: DBIOAction[T, NoStream, Effect.All]): Future[T] = db.run(action.transactionally) + + override def getSchedulesState( + scenarioName: ProcessName, + afterOpt: Option[LocalDateTime], + ): Action[SchedulesState] = { + PeriodicProcessesWithoutJson + .filter(_.processName === scenarioName) + .join(PeriodicProcessDeployments) + .on(_.id === _.periodicProcessId) + .filterOpt(afterOpt)((entities, after) => entities._2.completedAt > after) + .result + .map(toSchedulesStateForSinglePeriodicProcess) + } + + override def create( + deploymentWithRuntimeParams: DeploymentWithRuntimeParams, + inputConfigDuringExecutionJson: String, + canonicalProcess: CanonicalProcess, + scheduleProperty: ScheduleProperty, + processActionId: ProcessActionId, + ): Action[PeriodicProcess] = { + val jarFileName = deploymentWithRuntimeParams.runtimeParams.params.getOrElse( + jarFileNameRuntimeParam, + throw new RuntimeException(s"jarFileName runtime param not present") + ) + val processEntity = PeriodicProcessEntityWithJson( + id = PeriodicProcessId(-1), + processName = deploymentWithRuntimeParams.processName, + processVersionId = deploymentWithRuntimeParams.versionId, + processingType = processingType, + jarFileName = jarFileName, + scheduleProperty = scheduleProperty.asJson.noSpaces, + active = true, + createdAt = now(), + processActionId = Some(processActionId), + inputConfigDuringExecutionJson = inputConfigDuringExecutionJson, + processJson = canonicalProcess, + ) + ((PeriodicProcessesWithJson returning PeriodicProcessesWithJson into ((_, id) => id)) += processEntity) + .map(LegacyPeriodicProcessesRepository.createPeriodicProcess) + } + + private def now(): LocalDateTime = LocalDateTime.now(clock) + + override def findToBeDeployed: Action[Seq[PeriodicProcessDeployment]] = + findProcesses( + activePeriodicProcessWithDeploymentQuery(processingType) + .filter { case (_, d) => + d.runAt <= now() && + d.status === (PeriodicProcessDeploymentStatus.Scheduled: PeriodicProcessDeploymentStatus) + } + ) + + override def findToBeRetried: Action[Seq[PeriodicProcessDeployment]] = + findProcesses( + activePeriodicProcessWithDeploymentQuery(processingType) + .filter { case (_, d) => + d.nextRetryAt <= now() && + d.status === (PeriodicProcessDeploymentStatus.RetryingDeploy: PeriodicProcessDeploymentStatus) + } + ) + + private def findProcesses( + query: Query[ + (PeriodicProcessWithoutJson, PeriodicProcessDeploymentsTable), + (PeriodicProcessEntityWithoutJson, PeriodicProcessDeploymentEntity), + Seq + ] + ) = { + query.result + .map(_.map { case (periodicProcess, periodicDeployment) => + LegacyPeriodicProcessesRepository.createPeriodicProcessDeployment( + periodicProcess, + periodicDeployment, + ) + }) + } + + override def findProcessData(id: PeriodicProcessDeploymentId): Action[PeriodicProcessDeployment] = + findProcesses( + (PeriodicProcessesWithoutJson join PeriodicProcessDeployments on (_.id === _.periodicProcessId)) + .filter { case (_, deployment) => deployment.id === id } + ).map(_.head) + + override def markDeployed(id: PeriodicProcessDeploymentId): Action[Unit] = { + val q = for { + d <- PeriodicProcessDeployments if d.id === id + } yield (d.status, d.deployedAt) + val update = q.update((PeriodicProcessDeploymentStatus.Deployed, Some(now()))) + update.map(_ => ()) + } + + override def markFailed(id: PeriodicProcessDeploymentId): Action[Unit] = { + updateCompleted(id, PeriodicProcessDeploymentStatus.Failed) + } + + override def markFinished(id: PeriodicProcessDeploymentId): Action[Unit] = { + updateCompleted(id, PeriodicProcessDeploymentStatus.Finished) + } + + override def markFailedOnDeployWithStatus( + id: PeriodicProcessDeploymentId, + status: PeriodicProcessDeploymentStatus, + retriesLeft: Int, + retryAt: Option[LocalDateTime] + ): Action[Unit] = { + val q = for { + d <- PeriodicProcessDeployments if d.id === id + } yield (d.status, d.completedAt, d.retriesLeft, d.nextRetryAt) + val update = q.update((status, Some(now()), retriesLeft, retryAt)) + update.map(_ => ()) + } + + private def updateCompleted( + id: PeriodicProcessDeploymentId, + status: PeriodicProcessDeploymentStatus + ): Action[Unit] = { + val q = for { + d <- PeriodicProcessDeployments if d.id === id + } yield (d.status, d.completedAt) + val update = q.update((status, Some(now()))) + update.map(_ => ()) + } + + override def findActiveSchedulesForProcessesHavingDeploymentWithMatchingStatus( + expectedDeploymentStatuses: Set[PeriodicProcessDeploymentStatus], + ): Action[SchedulesState] = { + val processesHavingDeploymentsWithMatchingStatus = PeriodicProcessesWithoutJson.filter(p => + p.active && + PeriodicProcessDeployments + .filter(d => d.periodicProcessId === p.id && d.status.inSet(expectedDeploymentStatuses)) + .exists + ) + getLatestDeploymentsForEachSchedule( + processesHavingDeploymentsWithMatchingStatus, + deploymentsPerScheduleMaxCount = 1, + processingType = processingType, + ).map(schedulesForProcessNames => + SchedulesState( + schedulesForProcessNames.values.map(_.schedules).foldLeft(Map.empty[ScheduleId, ScheduleData])(_ ++ _) + ) + ) + } + + override def getLatestDeploymentsForActiveSchedules( + processName: ProcessName, + deploymentsPerScheduleMaxCount: Int, + ): Action[SchedulesState] = { + val activeProcessesQuery = + PeriodicProcessesWithoutJson.filter(p => p.processName === processName && p.active) + getLatestDeploymentsForEachSchedule(activeProcessesQuery, deploymentsPerScheduleMaxCount, processingType) + .map(_.getOrElse(processName, SchedulesState(Map.empty))) + } + + override def getLatestDeploymentsForActiveSchedules( + deploymentsPerScheduleMaxCount: Int, + ): Action[Map[ProcessName, SchedulesState]] = { + val activeProcessesQuery = PeriodicProcessesWithoutJson.filter(_.active) + getLatestDeploymentsForEachSchedule(activeProcessesQuery, deploymentsPerScheduleMaxCount, processingType) + } + + override def getLatestDeploymentsForLatestInactiveSchedules( + processName: ProcessName, + inactiveProcessesMaxCount: Int, + deploymentsPerScheduleMaxCount: Int, + ): Action[SchedulesState] = { + val filteredProcessesQuery = PeriodicProcessesWithoutJson + .filter(p => p.processName === processName && !p.active) + .sortBy(_.createdAt.desc) + .take(inactiveProcessesMaxCount) + getLatestDeploymentsForEachSchedule(filteredProcessesQuery, deploymentsPerScheduleMaxCount, processingType) + .map(_.getOrElse(processName, SchedulesState(Map.empty))) + } + + override def getLatestDeploymentsForLatestInactiveSchedules( + inactiveProcessesMaxCount: Int, + deploymentsPerScheduleMaxCount: Int, + ): Action[Map[ProcessName, SchedulesState]] = { + val filteredProcessesQuery = PeriodicProcessesWithoutJson + .filter(!_.active) + .sortBy(_.createdAt.desc) + .take(inactiveProcessesMaxCount) + getLatestDeploymentsForEachSchedule(filteredProcessesQuery, deploymentsPerScheduleMaxCount, processingType) + } + + private def getLatestDeploymentsForEachSchedule( + periodicProcessesQuery: Query[ + PeriodicProcessWithoutJson, + PeriodicProcessEntityWithoutJson, + Seq + ], + deploymentsPerScheduleMaxCount: Int, + processingType: String, + ): Action[Map[ProcessName, SchedulesState]] = { + val filteredPeriodicProcessQuery = periodicProcessesQuery.filter(p => p.processingType === processingType) + val latestDeploymentsForSchedules = profile match { + case _: ExPostgresProfile => + getLatestDeploymentsForEachSchedulePostgres(filteredPeriodicProcessQuery, deploymentsPerScheduleMaxCount) + case _ => + getLatestDeploymentsForEachScheduleJdbcGeneric(filteredPeriodicProcessQuery, deploymentsPerScheduleMaxCount) + } + latestDeploymentsForSchedules.map(toSchedulesState) + } + + private def getLatestDeploymentsForEachSchedulePostgres( + periodicProcessesQuery: Query[ + PeriodicProcessWithoutJson, + PeriodicProcessEntityWithoutJson, + Seq + ], + deploymentsPerScheduleMaxCount: Int + ): Action[Seq[(PeriodicProcessEntity, PeriodicProcessDeploymentEntity)]] = { + // To effectively limit deployments to given count for each schedule in one query, we use window functions in slick + import ExPostgresProfile.api._ + import com.github.tminglei.slickpg.window.PgWindowFuncSupport.WindowFunctions._ + + val deploymentsForProcesses = + periodicProcessesQuery join PeriodicProcessDeployments on (_.id === _.periodicProcessId) + deploymentsForProcesses + .map { case (process, deployment) => + ( + rowNumber() :: Over + .partitionBy((deployment.periodicProcessId, deployment.scheduleName)) + .sortBy( + deployment.runAt.desc, + deployment.createdAt.desc + ), // Remember to change DeploymentStatus.ordering accordingly + process, + deployment + ) + } + .subquery + .filter(_._1 <= deploymentsPerScheduleMaxCount.longValue()) + .map { case (_, process, deployment) => + (process, deployment) + } + .result + } + + // This variant of method is much less optimal than postgres one. It is highly recommended to use postgres with periodics + // If we decided to support more databases, we should consider some optimization like extracting periodic_schedule table + // with foreign key to periodic_process and with schedule_name column - it would reduce number of queries + private def getLatestDeploymentsForEachScheduleJdbcGeneric( + periodicProcessesQuery: Query[ + PeriodicProcessWithoutJson, + PeriodicProcessEntityWithoutJson, + Seq + ], + deploymentsPerScheduleMaxCount: Int + ): Action[Seq[(PeriodicProcessEntity, PeriodicProcessDeploymentEntity)]] = { + // It is debug instead of warn to not bloast logs when e.g. for some reasons is used hsql under the hood + logger.debug( + "WARN: Using not optimized version of getLatestDeploymentsForEachSchedule that not uses window functions" + ) + for { + processes <- periodicProcessesQuery.result + schedulesForProcesses <- + DBIO + .sequence(processes.map { process => + PeriodicProcessDeployments + .filter(_.periodicProcessId === process.id) + .map(_.scheduleName) + .distinct + .result + .map(_.map((process, _))) + }) + .map(_.flatten) + deploymentsForSchedules <- + DBIO + .sequence(schedulesForProcesses.map { case (process, scheduleName) => + PeriodicProcessDeployments + // In SQL when you compare nulls, you will get always false + .filter(deployment => + deployment.periodicProcessId === process.id && (deployment.scheduleName === scheduleName || deployment.scheduleName.isEmpty && scheduleName.isEmpty) + ) + .sortBy(a => (a.runAt.desc, a.createdAt.desc)) // Remember to change DeploymentStatus.ordering accordingly + .take(deploymentsPerScheduleMaxCount) + .result + .map(_.map((process, _))) + }) + .map(_.flatten) + } yield deploymentsForSchedules + } + + override def schedule( + id: PeriodicProcessId, + scheduleName: ScheduleName, + runAt: LocalDateTime, + deployMaxRetries: Int + ): Action[PeriodicProcessDeployment] = { + val deploymentEntity = PeriodicProcessDeploymentEntity( + id = PeriodicProcessDeploymentId(-1), + periodicProcessId = id, + createdAt = now(), + runAt = runAt, + scheduleName = scheduleName.value, + deployedAt = None, + completedAt = None, + retriesLeft = deployMaxRetries, + nextRetryAt = None, + status = PeriodicProcessDeploymentStatus.Scheduled + ) + ((PeriodicProcessDeployments returning PeriodicProcessDeployments.map(_.id) into ((_, id) => + id + )) += deploymentEntity).flatMap(findProcessData) + } + + override def markInactive(processId: PeriodicProcessId): Action[Unit] = { + val q = for { + p <- PeriodicProcessesWithoutJson if p.id === processId + } yield p.active + val update = q.update(false) + update.map(_ => ()) + } + + override def fetchCanonicalProcessWithVersion( + processName: ProcessName, + versionId: VersionId + ): Future[Option[(CanonicalProcess, ProcessVersion)]] = + fetchingProcessRepository.getCanonicalProcessWithVersion(processName, versionId)(NussknackerInternalUser.instance) + + def fetchInputConfigDuringExecutionJson(processName: ProcessName, versionId: VersionId): Action[Option[String]] = + PeriodicProcessesWithJson + .filter(p => p.processName === processName && p.processVersionId === versionId) + .map(_.inputConfigDuringExecutionJson) + .result + .headOption + + private def activePeriodicProcessWithDeploymentQuery(processingType: String) = { + (PeriodicProcessesWithoutJson.filter(p => p.active === true && p.processingType === processingType) + join PeriodicProcessDeployments on (_.id === _.periodicProcessId)) + } + + private def toSchedulesState( + list: Seq[(PeriodicProcessEntity, PeriodicProcessDeploymentEntity)] + ): Map[ProcessName, SchedulesState] = { + list + .groupBy(_._1.processName) + .map { case (processName, list) => processName -> toSchedulesStateForSinglePeriodicProcess(list) } + } + + private def toSchedulesStateForSinglePeriodicProcess( + list: Seq[(PeriodicProcessEntity, PeriodicProcessDeploymentEntity)] + ): SchedulesState = { + SchedulesState( + list + .map { case (process, deployment) => + val scheduleId = ScheduleId(process.id, ScheduleName(deployment.scheduleName)) + val scheduleData = (scheduleId, process) + val scheduleDeployment = scheduleDeploymentData(deployment) + (scheduleData, scheduleDeployment) + } + .toList + .toGroupedMap + .toList + .map { case ((scheduleId, process), deployments) => + scheduleId -> ScheduleData(createPeriodicProcess(process), deployments) + } + .toMap + ) + } + + private def scheduleDeploymentData(deployment: PeriodicProcessDeploymentEntity): ScheduleDeploymentData = { + ScheduleDeploymentData( + deployment.id, + deployment.createdAt, + deployment.runAt, + deployment.deployedAt, + deployment.retriesLeft, + deployment.nextRetryAt, + LegacyPeriodicProcessesRepository.createPeriodicDeploymentState(deployment) + ) + } + +} + +//Copied from designer/server. +object DBIOActionInstances { + + type DB[A] = DBIOAction[A, NoStream, Effect.All] + + implicit def dbMonad(implicit ec: ExecutionContext): Monad[DB] = new Monad[DB] { + + override def pure[A](x: A) = DBIO.successful(x) + + override def flatMap[A, B](fa: DB[A])(f: (A) => DB[B]) = fa.flatMap(f) + + // this is *not* tail recursive + override def tailRecM[A, B](a: A)(f: (A) => DB[Either[A, B]]): DB[B] = + f(a).flatMap { + case Right(r) => pure(r) + case Left(l) => tailRecM(l)(f) + } + + } + +} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesTable.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessesTableFactory.scala similarity index 92% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesTable.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessesTableFactory.scala index 1dfaef719eb..cf99e1ec652 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesTable.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/legacy/db/LegacyPeriodicProcessesTableFactory.scala @@ -1,37 +1,35 @@ -package pl.touk.nussknacker.engine.management.periodic.db +package pl.touk.nussknacker.ui.process.periodic.legacy.db import io.circe.syntax.EncoderOps import pl.touk.nussknacker.engine.api.deployment.ProcessActionId import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessId import pl.touk.nussknacker.engine.marshall.ProcessMarshaller -import slick.ast.TypedType +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessId import slick.jdbc.JdbcProfile -import slick.lifted.MappedToBase.mappedToIsomorphism import slick.lifted.ProvenShape import slick.sql.SqlProfile.ColumnOption.NotNull import java.time.LocalDateTime import java.util.UUID -trait PeriodicProcessesTableFactory { +trait LegacyPeriodicProcessesTableFactory { protected val profile: JdbcProfile import profile.api._ + implicit val periodicProcessIdMapping: BaseColumnType[PeriodicProcessId] = + MappedColumnType.base[PeriodicProcessId, Long](_.value, PeriodicProcessId.apply) + implicit val processNameMapping: BaseColumnType[ProcessName] = MappedColumnType.base[ProcessName, String](_.value, ProcessName.apply) implicit val versionIdMapping: BaseColumnType[VersionId] = - MappedColumnType.base[VersionId, Long](_.value, VersionId(_)) + MappedColumnType.base[VersionId, Long](_.value, VersionId.apply) - implicit val ProcessActionIdTypedType: TypedType[ProcessActionId] = - MappedColumnType.base[ProcessActionId, UUID]( - _.value, - ProcessActionId(_) - ) + implicit val processActionIdMapping: BaseColumnType[ProcessActionId] = + MappedColumnType.base[ProcessActionId, UUID](_.value, ProcessActionId.apply) abstract class PeriodicProcessesTable[ENTITY <: PeriodicProcessEntity](tag: Tag) extends Table[ENTITY](tag, "periodic_processes") { diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/PeriodicProcess.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/PeriodicProcess.scala new file mode 100644 index 00000000000..50c54e5b271 --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/PeriodicProcess.scala @@ -0,0 +1,18 @@ +package pl.touk.nussknacker.ui.process.periodic.model + +import pl.touk.nussknacker.engine.api.deployment.ProcessActionId +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.DeploymentWithRuntimeParams +import pl.touk.nussknacker.ui.process.periodic.ScheduleProperty + +import java.time.LocalDateTime + +case class PeriodicProcessId(value: Long) + +case class PeriodicProcess( + id: PeriodicProcessId, + deploymentData: DeploymentWithRuntimeParams, + scheduleProperty: ScheduleProperty, + active: Boolean, + createdAt: LocalDateTime, + processActionId: Option[ProcessActionId] +) diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/PeriodicProcessDeployment.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/PeriodicProcessDeployment.scala new file mode 100644 index 00000000000..dba8136bfdf --- /dev/null +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/PeriodicProcessDeployment.scala @@ -0,0 +1,71 @@ +package pl.touk.nussknacker.ui.process.periodic.model + +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{ScheduledDeploymentDetails, ScheduledDeploymentStatus} +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus.{ + Deployed, + Failed, + FailedOnDeploy, + Finished, + PeriodicProcessDeploymentStatus, + RetryingDeploy, + Scheduled +} + +import java.time.LocalDateTime + +// TODO: We should separate schedules concept from deployments - fully switch to ScheduleData and ScheduleDeploymentData +case class PeriodicProcessDeployment( + id: PeriodicProcessDeploymentId, + periodicProcess: PeriodicProcess, + createdAt: LocalDateTime, + runAt: LocalDateTime, + scheduleName: ScheduleName, + retriesLeft: Int, + nextRetryAt: Option[LocalDateTime], + state: PeriodicProcessDeploymentState +) { + + def display: String = + s"Process with id=${periodicProcess.deploymentData.processId}, name=${periodicProcess.deploymentData.processName}, versionId=${periodicProcess.deploymentData.versionId}, scheduleName=${scheduleName.display} and deploymentId=$id" + + def toDetails: ScheduledDeploymentDetails = + ScheduledDeploymentDetails( + id = id.value, + processName = periodicProcess.deploymentData.processName, + versionId = periodicProcess.deploymentData.versionId, + scheduleName = scheduleName.value, + createdAt = createdAt, + runAt = runAt, + deployedAt = state.deployedAt, + completedAt = state.completedAt, + status = state.status match { + case Scheduled => ScheduledDeploymentStatus.Scheduled + case Deployed => ScheduledDeploymentStatus.Deployed + case Finished => ScheduledDeploymentStatus.Finished + case Failed => ScheduledDeploymentStatus.Failed + case RetryingDeploy => ScheduledDeploymentStatus.RetryingDeploy + case FailedOnDeploy => ScheduledDeploymentStatus.FailedOnDeploy + }, + ) + +} + +case class PeriodicProcessDeploymentState( + deployedAt: Option[LocalDateTime], + completedAt: Option[LocalDateTime], + status: PeriodicProcessDeploymentStatus +) + +case class PeriodicProcessDeploymentId(value: Long) { + override def toString: String = value.toString +} + +object PeriodicProcessDeploymentStatus extends Enumeration { + type PeriodicProcessDeploymentStatus = Value + + val Scheduled, Deployed, Finished, Failed, RetryingDeploy, FailedOnDeploy = Value +} + +case class ScheduleName(value: Option[String]) { + def display: String = value.getOrElse("[default]") +} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/SchedulesState.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/SchedulesState.scala similarity index 66% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/SchedulesState.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/SchedulesState.scala index ca697fe27d9..b4161cffe46 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/SchedulesState.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/model/SchedulesState.scala @@ -1,8 +1,6 @@ -package pl.touk.nussknacker.engine.management.periodic.model +package pl.touk.nussknacker.ui.process.periodic.model import pl.touk.nussknacker.engine.api.process.ProcessName -import pl.touk.nussknacker.engine.management.periodic.db.{PeriodicProcessDeploymentEntity, PeriodicProcessesRepository} -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithoutCanonicalProcess import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap import java.time.LocalDateTime @@ -20,7 +18,7 @@ case class SchedulesState(schedules: Map[ScheduleId, ScheduleData]) { def isEmpty: Boolean = schedules.isEmpty def groupByProcessName: Map[ProcessName, SchedulesState] = - schedules.groupBy(_._2.process.processVersion.processName).mapValuesNow(SchedulesState) + schedules.groupBy(_._2.process.deploymentData.processName).mapValuesNow(SchedulesState) lazy val groupedByPeriodicProcess: List[PeriodicProcessScheduleData] = schedules.toList.groupBy(_._2.process).toList.map { case (periodicProcess, groupedSchedules) => @@ -36,10 +34,7 @@ case class SchedulesState(schedules: Map[ScheduleId, ScheduleData]) { // For most operations it will contain only one latest deployment but for purpose of statuses of historical deployments // it has list instead of one element. // This structure should contain SingleScheduleProperty as well. See note above -case class ScheduleData( - process: PeriodicProcess[WithoutCanonicalProcess], - latestDeployments: List[ScheduleDeploymentData] -) +case class ScheduleData(process: PeriodicProcess, latestDeployments: List[ScheduleDeploymentData]) // To identify schedule we need scheduleName - None for SingleScheduleProperty and Some(key) for MultipleScheduleProperty keys // Also we need PeriodicProcessId to distinguish between active schedules and some inactive from the past for the same PeriodicProcessId @@ -57,42 +52,17 @@ case class ScheduleDeploymentData( ) { def toFullDeploymentData( - process: PeriodicProcess[WithoutCanonicalProcess], + process: PeriodicProcess, scheduleName: ScheduleName - ): PeriodicProcessDeployment[WithoutCanonicalProcess] = + ): PeriodicProcessDeployment = PeriodicProcessDeployment(id, process, createdAt, runAt, scheduleName, retriesLeft, nextRetryAt, state) def display = s"deploymentId=$id" } -object ScheduleDeploymentData { - - def apply(deployment: PeriodicProcessDeploymentEntity): ScheduleDeploymentData = { - ScheduleDeploymentData( - deployment.id, - deployment.createdAt, - deployment.runAt, - deployment.deployedAt, - deployment.retriesLeft, - deployment.nextRetryAt, - PeriodicProcessesRepository.createPeriodicDeploymentState(deployment) - ) - } - -} - // These below are temporary structures, see notice next to SchedulesState case class PeriodicProcessScheduleData( - process: PeriodicProcess[WithoutCanonicalProcess], - deployments: List[PeriodicProcessDeployment[WithoutCanonicalProcess]] -) { - def existsDeployment(predicate: PeriodicProcessDeployment[WithoutCanonicalProcess] => Boolean): Boolean = - deployments.exists(predicate) - - def display: String = { - val deploymentsForSchedules = deployments.map(_.display) - s"processName=${process.processVersion.processName}, deploymentsForSchedules=$deploymentsForSchedules" - } - -} + process: PeriodicProcess, + deployments: List[PeriodicProcessDeployment] +) diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/util/DeterministicUUIDFromLong.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/utils/DeterministicUUIDFromLong.scala similarity index 93% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/util/DeterministicUUIDFromLong.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/utils/DeterministicUUIDFromLong.scala index 3f1a80b09b4..4cf890790cf 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/util/DeterministicUUIDFromLong.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/utils/DeterministicUUIDFromLong.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic.util +package pl.touk.nussknacker.ui.process.periodic.utils import java.util.UUID diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SchedulePropertyExtractor.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/utils/SchedulePropertyExtractorUtils.scala similarity index 76% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SchedulePropertyExtractor.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/utils/SchedulePropertyExtractorUtils.scala index 989d625cb51..0e2ccb4bd55 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SchedulePropertyExtractor.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/periodic/utils/SchedulePropertyExtractorUtils.scala @@ -1,18 +1,13 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.utils import cats.instances.list._ import cats.syntax.traverse._ -import com.typesafe.scalalogging.LazyLogging import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.CronSchedulePropertyExtractor.CronPropertyDefaultName +import pl.touk.nussknacker.ui.process.periodic.{CronScheduleProperty, MultipleScheduleProperty, ScheduleProperty, SingleScheduleProperty} import java.time.Clock -trait SchedulePropertyExtractor { - def apply(canonicalProcess: CanonicalProcess): Either[String, ScheduleProperty] -} - -object SchedulePropertyExtractor { +object SchedulePropertyExtractorUtils { def extractProperty(canonicalProcess: CanonicalProcess, name: String): Either[String, ScheduleProperty] = { for { @@ -75,19 +70,3 @@ object SchedulePropertyExtractor { } } - -object CronSchedulePropertyExtractor { - - val CronPropertyDefaultName = "cron" - -} - -case class CronSchedulePropertyExtractor(propertyName: String = CronPropertyDefaultName) - extends SchedulePropertyExtractor - with LazyLogging { - - override def apply(canonicalProcess: CanonicalProcess): Either[String, ScheduleProperty] = { - SchedulePropertyExtractor.extractProperty(canonicalProcess, propertyName) - } - -} diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/InvalidDeploymentManagerStub.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/InvalidDeploymentManagerStub.scala index df7f930b571..ee66bfe9b5b 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/InvalidDeploymentManagerStub.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/InvalidDeploymentManagerStub.scala @@ -54,5 +54,7 @@ object InvalidDeploymentManagerStub extends DeploymentManager { override def stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport = NoStateQueryForAllScenariosSupport + override def schedulingSupport: SchedulingSupport = NoSchedulingSupport + override def close(): Unit = () } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeData.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeData.scala index 9390503101c..00aa215ed26 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeData.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeData.scala @@ -4,6 +4,7 @@ import com.typesafe.config.Config import pl.touk.nussknacker.engine._ import pl.touk.nussknacker.engine.api.component.ScenarioPropertyConfig import pl.touk.nussknacker.engine.api.deployment.cache.ScenarioStateCachingConfig +import pl.touk.nussknacker.engine.api.deployment.{NoSchedulingSupport, SchedulingSupported} import pl.touk.nussknacker.engine.api.process.ProcessingType import pl.touk.nussknacker.engine.definition.component.Components.ComponentDefinitionExtractionMode import pl.touk.nussknacker.engine.definition.component.{ @@ -13,6 +14,8 @@ import pl.touk.nussknacker.engine.definition.component.{ } import pl.touk.nussknacker.engine.deployment.EngineSetupName import pl.touk.nussknacker.restmodel.scenariodetails.ScenarioParameters +import pl.touk.nussknacker.ui.db.DbRef +import pl.touk.nussknacker.ui.process.periodic.{PeriodicDeploymentManagerDecorator, SchedulingConfig} import pl.touk.nussknacker.ui.process.processingtype.DesignerModelData.DynamicComponentsStaticDefinitions import scala.util.control.NonFatal @@ -53,6 +56,7 @@ object ProcessingTypeData { name: ProcessingType, modelData: ModelData, deploymentManagerProvider: DeploymentManagerProvider, + schedulingForProcessingType: SchedulingForProcessingType, deploymentManagerDependencies: DeploymentManagerDependencies, engineSetupName: EngineSetupName, deploymentConfig: Config, @@ -64,11 +68,12 @@ object ProcessingTypeData { val deploymentData = createDeploymentData( deploymentManagerProvider, + schedulingForProcessingType, deploymentManagerDependencies, engineSetupName, modelData, deploymentConfig, - metaDataInitializer + metaDataInitializer, ) val designerModelData = @@ -90,22 +95,52 @@ object ProcessingTypeData { private def createDeploymentData( deploymentManagerProvider: DeploymentManagerProvider, + schedulingForProcessingType: SchedulingForProcessingType, deploymentManagerDependencies: DeploymentManagerDependencies, engineSetupName: EngineSetupName, modelData: ModelData, deploymentConfig: Config, - metaDataInitializer: MetaDataInitializer + metaDataInitializer: MetaDataInitializer, ) = { val scenarioStateCacheTTL = ScenarioStateCachingConfig.extractScenarioStateCacheTTL(deploymentConfig) - val validDeploymentManager = - deploymentManagerProvider.createDeploymentManager( + val validDeploymentManager = for { + deploymentManager <- deploymentManagerProvider.createDeploymentManager( modelData, deploymentManagerDependencies, deploymentConfig, scenarioStateCacheTTL ) - val scenarioProperties = + decoratedDeploymentManager = schedulingForProcessingType match { + case SchedulingForProcessingType.Available(dbRef) => + deploymentManager.schedulingSupport match { + case supported: SchedulingSupported => + PeriodicDeploymentManagerDecorator.decorate( + underlying = deploymentManager, + schedulingSupported = supported, + modelData = modelData, + deploymentConfig = deploymentConfig, + dependencies = deploymentManagerDependencies, + dbRef = dbRef, + ) + case NoSchedulingSupport => + throw new IllegalStateException( + s"DeploymentManager ${deploymentManagerProvider.name} does not support periodic execution" + ) + } + + case SchedulingForProcessingType.NotAvailable => + deploymentManager + } + } yield decoratedDeploymentManager + + val additionalScenarioProperties = schedulingForProcessingType match { + case SchedulingForProcessingType.Available(_) => + PeriodicDeploymentManagerDecorator.additionalScenarioProperties + case SchedulingForProcessingType.NotAvailable => + Map.empty[String, ScenarioPropertyConfig] + } + val scenarioProperties = additionalScenarioProperties ++ deploymentManagerProvider.scenarioPropertiesConfig(deploymentConfig) ++ modelData.modelConfig .getOrElse[Map[ProcessingType, ScenarioPropertyConfig]]("scenarioPropertiesConfig", Map.empty) val fragmentProperties = modelData.modelConfig @@ -163,4 +198,14 @@ object ProcessingTypeData { ) } + sealed trait SchedulingForProcessingType + + object SchedulingForProcessingType { + + case object NotAvailable extends SchedulingForProcessingType + + final case class Available(dbRef: DbRef) extends SchedulingForProcessingType + + } + } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/LocalProcessingTypeDataLoader.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/LocalProcessingTypeDataLoader.scala index c5676f87cd3..46f28fd3b6b 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/LocalProcessingTypeDataLoader.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/LocalProcessingTypeDataLoader.scala @@ -5,6 +5,8 @@ import com.typesafe.config.ConfigFactory import pl.touk.nussknacker.engine._ import pl.touk.nussknacker.engine.api.process.ProcessingType import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap +import pl.touk.nussknacker.ui.db.DbRef +import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData.SchedulingForProcessingType import pl.touk.nussknacker.ui.process.processingtype.loader.ProcessingTypeDataLoader.toValueWithRestriction import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataState import pl.touk.nussknacker.ui.process.processingtype.{ @@ -21,7 +23,8 @@ class LocalProcessingTypeDataLoader( override def loadProcessingTypeData( getModelDependencies: ProcessingType => ModelDependencies, getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies, - modelClassLoaderProvider: ModelClassLoaderProvider + modelClassLoaderProvider: ModelClassLoaderProvider, + dbRef: Option[DbRef], ): IO[ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData]] = IO { val processingTypes = modelData.map { case (processingType, (category, model)) => val deploymentManagerDependencies = getDeploymentManagerDependencies(processingType) @@ -29,11 +32,12 @@ class LocalProcessingTypeDataLoader( name = processingType, modelData = model, deploymentManagerProvider = deploymentManagerProvider, + schedulingForProcessingType = SchedulingForProcessingType.NotAvailable, deploymentManagerDependencies = deploymentManagerDependencies, engineSetupName = deploymentManagerProvider.defaultEngineSetupName, deploymentConfig = ConfigFactory.empty(), category = category, - componentDefinitionExtractionMode = getModelDependencies(processingType).componentDefinitionExtractionMode + componentDefinitionExtractionMode = getModelDependencies(processingType).componentDefinitionExtractionMode, ) processingType -> data } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypeDataLoader.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypeDataLoader.scala index 28fa2bb20bc..277dd6b96a1 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypeDataLoader.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypeDataLoader.scala @@ -3,6 +3,7 @@ package pl.touk.nussknacker.ui.process.processingtype.loader import cats.effect.IO import pl.touk.nussknacker.engine.api.process.ProcessingType import pl.touk.nussknacker.engine.{DeploymentManagerDependencies, ModelDependencies} +import pl.touk.nussknacker.ui.db.DbRef import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataState import pl.touk.nussknacker.ui.process.processingtype.{ CombinedProcessingTypeData, @@ -16,7 +17,10 @@ trait ProcessingTypeDataLoader { def loadProcessingTypeData( getModelDependencies: ProcessingType => ModelDependencies, getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies, - modelClassLoaderProvider: ModelClassLoaderProvider + modelClassLoaderProvider: ModelClassLoaderProvider, + // should be always available, used by scheduling mechanism, + // but in tests sometimes we do not want to bootstrap the full environment with db + dbRef: Option[DbRef], ): IO[ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData]] } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypesConfigBasedProcessingTypeDataLoader.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypesConfigBasedProcessingTypeDataLoader.scala index c1d31b5e5c2..6f7e96d6e67 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypesConfigBasedProcessingTypeDataLoader.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/processingtype/loader/ProcessingTypesConfigBasedProcessingTypeDataLoader.scala @@ -7,6 +7,8 @@ import pl.touk.nussknacker.engine.api.process.ProcessingType import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap import pl.touk.nussknacker.engine.util.loader.ScalaServiceLoader import pl.touk.nussknacker.ui.configloader.{ProcessingTypeConfigs, ProcessingTypeConfigsLoader} +import pl.touk.nussknacker.ui.db.DbRef +import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData.SchedulingForProcessingType import pl.touk.nussknacker.ui.process.processingtype._ import pl.touk.nussknacker.ui.process.processingtype.loader.ProcessingTypeDataLoader.toValueWithRestriction import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataState @@ -18,12 +20,19 @@ class ProcessingTypesConfigBasedProcessingTypeDataLoader(processingTypeConfigsLo override def loadProcessingTypeData( getModelDependencies: ProcessingType => ModelDependencies, getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies, - modelClassLoaderProvider: ModelClassLoaderProvider + modelClassLoaderProvider: ModelClassLoaderProvider, + dbRef: Option[DbRef], ): IO[ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData]] = { processingTypeConfigsLoader .loadProcessingTypeConfigs() .map( - createProcessingTypeData(_, getModelDependencies, getDeploymentManagerDependencies, modelClassLoaderProvider) + createProcessingTypeData( + _, + getModelDependencies, + getDeploymentManagerDependencies, + modelClassLoaderProvider, + dbRef + ) ) } @@ -31,7 +40,8 @@ class ProcessingTypesConfigBasedProcessingTypeDataLoader(processingTypeConfigsLo processingTypesConfig: ProcessingTypeConfigs, getModelDependencies: ProcessingType => ModelDependencies, getDeploymentManagerDependencies: ProcessingType => DeploymentManagerDependencies, - modelClassLoaderProvider: ModelClassLoaderProvider + modelClassLoaderProvider: ModelClassLoaderProvider, + dbRef: Option[DbRef], ): ProcessingTypeDataState[ProcessingTypeData, CombinedProcessingTypeData] = { // This step with splitting DeploymentManagerProvider loading for all processing types // and after that creating ProcessingTypeData is done because of the deduplication of deployments @@ -57,17 +67,30 @@ class ProcessingTypesConfigBasedProcessingTypeDataLoader(processingTypeConfigsLo val processingTypesData = providerWithNameInputData .map { case (processingType, (processingTypeConfig, deploymentManagerProvider, _)) => logger.debug(s"Creating Processing Type: $processingType with config: $processingTypeConfig") + val schedulingForProcessingType = + if (processingTypeConfig.deploymentConfig.hasPath("scheduling") && + processingTypeConfig.deploymentConfig.getBoolean("scheduling.enabled")) { + SchedulingForProcessingType.Available( + dbRef.getOrElse( + throw new RuntimeException(s"dbRef not present, but required for Dm with scheduling enabled") + ), + ) + } else { + SchedulingForProcessingType.NotAvailable + } + val modelDependencies = getModelDependencies(processingType) val modelClassLoader = modelClassLoaderProvider.forProcessingTypeUnsafe(processingType) val processingTypeData = ProcessingTypeData.createProcessingTypeData( processingType, ModelData(processingTypeConfig, modelDependencies, modelClassLoader), deploymentManagerProvider, + schedulingForProcessingType, getDeploymentManagerDependencies(processingType), engineSetupNames(processingType), processingTypeConfig.deploymentConfig, processingTypeConfig.category, - modelDependencies.componentDefinitionExtractionMode + modelDependencies.componentDefinitionExtractionMode, ) processingType -> processingTypeData } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepository.scala index c0ff82ef570..86450941a90 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepository.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/DBFetchingProcessRepository.scala @@ -5,8 +5,10 @@ import cats.data.OptionT import cats.instances.future._ import com.typesafe.scalalogging.LazyLogging import db.util.DBIOActionInstances._ +import pl.touk.nussknacker.engine.api.ProcessVersion import pl.touk.nussknacker.engine.api.deployment.{ProcessAction, ProcessActionState, ScenarioActionName} import pl.touk.nussknacker.engine.api.process._ +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.ui.db.DbRef import pl.touk.nussknacker.ui.db.entity._ import pl.touk.nussknacker.ui.process.label.ScenarioLabel @@ -22,19 +24,20 @@ object DBFetchingProcessRepository { def create( dbRef: DbRef, - actionRepository: ScenarioActionRepository, + actionRepository: ScenarioActionReadOnlyRepository, scenarioLabelsRepository: ScenarioLabelsRepository )(implicit ec: ExecutionContext) = new DBFetchingProcessRepository[DB](dbRef, actionRepository, scenarioLabelsRepository) with DbioRepository def createFutureRepository( dbRef: DbRef, - actionRepository: ScenarioActionRepository, + actionReadOnlyRepository: ScenarioActionReadOnlyRepository, scenarioLabelsRepository: ScenarioLabelsRepository )( implicit ec: ExecutionContext ) = - new DBFetchingProcessRepository[Future](dbRef, actionRepository, scenarioLabelsRepository) with BasicRepository + new DBFetchingProcessRepository[Future](dbRef, actionReadOnlyRepository, scenarioLabelsRepository) + with BasicRepository } @@ -43,7 +46,7 @@ object DBFetchingProcessRepository { // to the resource on the services side abstract class DBFetchingProcessRepository[F[_]: Monad]( protected val dbRef: DbRef, - actionRepository: ScenarioActionRepository, + actionRepository: ScenarioActionReadOnlyRepository, scenarioLabelsRepository: ScenarioLabelsRepository, )(protected implicit val ec: ExecutionContext) extends FetchingProcessRepository[F] @@ -51,6 +54,22 @@ abstract class DBFetchingProcessRepository[F[_]: Monad]( import api._ + override def getCanonicalProcessWithVersion( + processName: ProcessName, + versionId: VersionId + )( + implicit user: LoggedUser, + ): F[Option[(CanonicalProcess, ProcessVersion)]] = { + val result = for { + processId <- OptionT(fetchProcessId(processName)) + details <- OptionT(fetchProcessDetailsForId[CanonicalProcess](processId, versionId)) + } yield ( + details.json, + details.toEngineProcessVersion, + ) + result.value + } + override def fetchLatestProcessesDetails[PS: ScenarioShapeFetchStrategy]( query: ScenarioQuery )(implicit loggedUser: LoggedUser, ec: ExecutionContext): F[List[ScenarioWithDetailsEntity[PS]]] = { diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/FetchingProcessRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/FetchingProcessRepository.scala index db88f7dd0a3..c404f705218 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/FetchingProcessRepository.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/FetchingProcessRepository.scala @@ -1,7 +1,9 @@ package pl.touk.nussknacker.ui.process.repository import cats.Monad -import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessIdWithName, ProcessName, ProcessingType, VersionId} +import pl.touk.nussknacker.engine.api.ProcessVersion +import pl.touk.nussknacker.engine.api.process._ +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.ui.process.ScenarioQuery import pl.touk.nussknacker.ui.security.api.LoggedUser @@ -23,6 +25,13 @@ abstract class FetchingProcessRepository[F[_]: Monad] extends ProcessDBQueryRepo query: ScenarioQuery )(implicit loggedUser: LoggedUser, ec: ExecutionContext): F[List[ScenarioWithDetailsEntity[PS]]] + def getCanonicalProcessWithVersion( + processName: ProcessName, + versionId: VersionId + )( + implicit user: LoggedUser, + ): F[Option[(CanonicalProcess, ProcessVersion)]] + def fetchProcessId(processName: ProcessName)(implicit ec: ExecutionContext): F[Option[ProcessId]] def fetchProcessName(processId: ProcessId)(implicit ec: ExecutionContext): F[Option[ProcessName]] diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/PeriodicProcessesRepository.scala similarity index 65% rename from engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesRepository.scala rename to designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/PeriodicProcessesRepository.scala index 24cf6c47dac..adbc64164ac 100644 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/db/PeriodicProcessesRepository.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/PeriodicProcessesRepository.scala @@ -1,20 +1,22 @@ -package pl.touk.nussknacker.engine.management.periodic.db +package pl.touk.nussknacker.ui.process.repository -import cats.Monad import com.github.tminglei.slickpg.ExPostgresProfile import com.typesafe.scalalogging.LazyLogging +import db.util.DBIOActionInstances +import db.util.DBIOActionInstances.DB import io.circe.parser.decode +import io.circe.syntax.EncoderOps import pl.touk.nussknacker.engine.api.ProcessVersion import pl.touk.nussknacker.engine.api.deployment.ProcessActionId -import pl.touk.nussknacker.engine.api.process.ProcessName -import pl.touk.nussknacker.engine.management.periodic._ -import pl.touk.nussknacker.engine.management.periodic.db.PeriodicProcessesRepository.createPeriodicProcessWithoutJson -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.{ - WithCanonicalProcess, - WithoutCanonicalProcess -} -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus -import pl.touk.nussknacker.engine.management.periodic.model._ +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.DeploymentWithRuntimeParams +import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId} +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.ui.db.entity._ +import pl.touk.nussknacker.ui.process.periodic.ScheduleProperty +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus +import pl.touk.nussknacker.ui.process.periodic.model._ +import pl.touk.nussknacker.ui.process.repository.PeriodicProcessesRepository.createPeriodicProcess +import pl.touk.nussknacker.ui.security.api.NussknackerInternalUser import slick.dbio.{DBIOAction, Effect, NoStream} import slick.jdbc.PostgresProfile.api._ import slick.jdbc.{JdbcBackend, JdbcProfile} @@ -26,10 +28,10 @@ import scala.language.higherKinds object PeriodicProcessesRepository { def createPeriodicProcessDeployment( - processEntity: PeriodicProcessEntityWithJson, + processEntity: PeriodicProcessEntity, processDeploymentEntity: PeriodicProcessDeploymentEntity - ): PeriodicProcessDeployment[WithCanonicalProcess] = { - val process = createPeriodicProcessWithJson(processEntity) + ): PeriodicProcessDeployment = { + val process = createPeriodicProcess(processEntity) PeriodicProcessDeployment( processDeploymentEntity.id, process, @@ -52,36 +54,17 @@ object PeriodicProcessesRepository { ) } - def createPeriodicProcessWithJson( - processEntity: PeriodicProcessEntityWithJson - ): PeriodicProcess[WithCanonicalProcess] = { - val processVersion = createProcessVersion(processEntity) - val scheduleProperty = prepareScheduleProperty(processEntity) - PeriodicProcess( - processEntity.id, - model.DeploymentWithJarData.WithCanonicalProcess( - processVersion = processVersion, - inputConfigDuringExecutionJson = processEntity.inputConfigDuringExecutionJson, - jarFileName = processEntity.jarFileName, - process = processEntity.processJson, - ), - scheduleProperty, - processEntity.active, - processEntity.createdAt, - processEntity.processActionId - ) - } - - def createPeriodicProcessWithoutJson( + def createPeriodicProcess( processEntity: PeriodicProcessEntity - ): PeriodicProcess[WithoutCanonicalProcess] = { - val processVersion = createProcessVersion(processEntity) + ): PeriodicProcess = { val scheduleProperty = prepareScheduleProperty(processEntity) PeriodicProcess( processEntity.id, - model.DeploymentWithJarData.WithoutCanonicalProcess( - processVersion = processVersion, - jarFileName = processEntity.jarFileName, + DeploymentWithRuntimeParams( + processId = processEntity.processId, + processName = processEntity.processName, + versionId = processEntity.processVersionId, + runtimeParams = processEntity.runtimeParams, ), scheduleProperty, processEntity.active, @@ -96,18 +79,12 @@ object PeriodicProcessesRepository { scheduleProperty } - private def createProcessVersion(processEntity: PeriodicProcessEntity): ProcessVersion = { - ProcessVersion.empty.copy(versionId = processEntity.processVersionId, processName = processEntity.processName) - } - } trait PeriodicProcessesRepository { type Action[_] - implicit def monad: Monad[Action] - implicit class RunOps[T](action: Action[T]) { def run: Future[T] = PeriodicProcessesRepository.this.run(action) } @@ -122,42 +99,42 @@ trait PeriodicProcessesRepository { ): Action[SchedulesState] def create( - deploymentWithJarData: DeploymentWithJarData.WithCanonicalProcess, + deploymentWithRuntimeParams: DeploymentWithRuntimeParams, + inputConfigDuringExecutionJson: String, + canonicalProcess: CanonicalProcess, scheduleProperty: ScheduleProperty, - processActionId: ProcessActionId - ): Action[PeriodicProcess[WithCanonicalProcess]] + processActionId: ProcessActionId, + ): Action[PeriodicProcess] def getLatestDeploymentsForActiveSchedules( processName: ProcessName, - deploymentsPerScheduleMaxCount: Int + deploymentsPerScheduleMaxCount: Int, ): Action[SchedulesState] def getLatestDeploymentsForActiveSchedules( - deploymentsPerScheduleMaxCount: Int + deploymentsPerScheduleMaxCount: Int, ): Action[Map[ProcessName, SchedulesState]] def getLatestDeploymentsForLatestInactiveSchedules( processName: ProcessName, inactiveProcessesMaxCount: Int, - deploymentsPerScheduleMaxCount: Int + deploymentsPerScheduleMaxCount: Int, ): Action[SchedulesState] def getLatestDeploymentsForLatestInactiveSchedules( inactiveProcessesMaxCount: Int, - deploymentsPerScheduleMaxCount: Int + deploymentsPerScheduleMaxCount: Int, ): Action[Map[ProcessName, SchedulesState]] - def findToBeDeployed: Action[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]] + def findToBeDeployed: Action[Seq[PeriodicProcessDeployment]] - def findToBeRetried: Action[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]] + def findToBeRetried: Action[Seq[PeriodicProcessDeployment]] def findActiveSchedulesForProcessesHavingDeploymentWithMatchingStatus( - expectedDeploymentStatuses: Set[PeriodicProcessDeploymentStatus] + expectedDeploymentStatuses: Set[PeriodicProcessDeploymentStatus], ): Action[SchedulesState] - def findProcessData(id: PeriodicProcessDeploymentId): Action[PeriodicProcessDeployment[WithCanonicalProcess]] - - def findProcessData(processName: ProcessName): Action[Seq[PeriodicProcess[WithCanonicalProcess]]] + def findProcessData(id: PeriodicProcessDeploymentId): Action[PeriodicProcessDeployment] def markDeployed(id: PeriodicProcessDeploymentId): Action[Unit] @@ -177,35 +154,43 @@ trait PeriodicProcessesRepository { scheduleName: ScheduleName, runAt: LocalDateTime, deployMaxRetries: Int - ): Action[PeriodicProcessDeployment[WithCanonicalProcess]] + ): Action[PeriodicProcessDeployment] + + def fetchInputConfigDuringExecutionJson( + processName: ProcessName, + versionId: VersionId + ): Action[Option[String]] + + def fetchCanonicalProcessWithVersion( + processName: ProcessName, + versionId: VersionId + ): Future[Option[(CanonicalProcess, ProcessVersion)]] } class SlickPeriodicProcessesRepository( + processingType: String, db: JdbcBackend.DatabaseDef, override val profile: JdbcProfile, clock: Clock, - processingType: String + fetchingProcessRepository: FetchingProcessRepository[Future], )(implicit ec: ExecutionContext) extends PeriodicProcessesRepository with PeriodicProcessesTableFactory with PeriodicProcessDeploymentsTableFactory with LazyLogging { - import io.circe.syntax._ import pl.touk.nussknacker.engine.util.Implicits._ type Action[T] = DBIOActionInstances.DB[T] - override implicit def monad: Monad[Action] = DBIOActionInstances.dbMonad - override def run[T](action: DBIOAction[T, NoStream, Effect.All]): Future[T] = db.run(action.transactionally) override def getSchedulesState( scenarioName: ProcessName, afterOpt: Option[LocalDateTime], ): Action[SchedulesState] = { - PeriodicProcessesWithoutJson + PeriodicProcessesWithoutInputConfig .filter(_.processName === scenarioName) .join(PeriodicProcessDeployments) .on(_.id === _.periodicProcessId) @@ -215,63 +200,72 @@ class SlickPeriodicProcessesRepository( } override def create( - deploymentWithJarData: DeploymentWithJarData.WithCanonicalProcess, + deploymentWithRuntimeParams: DeploymentWithRuntimeParams, + inputConfigDuringExecutionJson: String, + canonicalProcess: CanonicalProcess, scheduleProperty: ScheduleProperty, - processActionId: ProcessActionId - ): Action[PeriodicProcess[WithCanonicalProcess]] = { - val processEntity = PeriodicProcessEntityWithJson( + processActionId: ProcessActionId, + ): Action[PeriodicProcess] = { + val processEntity = PeriodicProcessEntityWithInputConfigJson( id = PeriodicProcessId(-1), - processName = deploymentWithJarData.processVersion.processName, - processVersionId = deploymentWithJarData.processVersion.versionId, + processId = deploymentWithRuntimeParams.processId, + processName = deploymentWithRuntimeParams.processName, + processVersionId = deploymentWithRuntimeParams.versionId, processingType = processingType, - processJson = deploymentWithJarData.process, - inputConfigDuringExecutionJson = deploymentWithJarData.inputConfigDuringExecutionJson, - jarFileName = deploymentWithJarData.jarFileName, + runtimeParams = deploymentWithRuntimeParams.runtimeParams, scheduleProperty = scheduleProperty.asJson.noSpaces, active = true, createdAt = now(), - Some(processActionId) + Some(processActionId), + inputConfigDuringExecutionJson = inputConfigDuringExecutionJson, ) - ((PeriodicProcessesWithJson returning PeriodicProcessesWithJson into ((_, id) => id)) += processEntity) - .map(PeriodicProcessesRepository.createPeriodicProcessWithJson) + ((PeriodicProcessesWithInputConfig returning PeriodicProcessesWithInputConfig into ((_, id) => + id + )) += processEntity) + .map(PeriodicProcessesRepository.createPeriodicProcess) } private def now(): LocalDateTime = LocalDateTime.now(clock) - override def findToBeDeployed: Action[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]] = - activePeriodicProcessWithDeploymentQuery - .filter { case (_, d) => - d.runAt <= now() && - d.status === (PeriodicProcessDeploymentStatus.Scheduled: PeriodicProcessDeploymentStatus) - } - .result - .map(_.map((PeriodicProcessesRepository.createPeriodicProcessDeployment _).tupled)) + override def findToBeDeployed: Action[Seq[PeriodicProcessDeployment]] = + findProcesses( + activePeriodicProcessWithDeploymentQuery(processingType) + .filter { case (_, d) => + d.runAt <= now() && + d.status === (PeriodicProcessDeploymentStatus.Scheduled: PeriodicProcessDeploymentStatus) + } + ) - override def findToBeRetried: Action[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]] = - activePeriodicProcessWithDeploymentQuery - .filter { case (_, d) => - d.nextRetryAt <= now() && - d.status === (PeriodicProcessDeploymentStatus.RetryingDeploy: PeriodicProcessDeploymentStatus) - } - .result - .map(_.map((PeriodicProcessesRepository.createPeriodicProcessDeployment _).tupled)) + override def findToBeRetried: Action[Seq[PeriodicProcessDeployment]] = + findProcesses( + activePeriodicProcessWithDeploymentQuery(processingType) + .filter { case (_, d) => + d.nextRetryAt <= now() && + d.status === (PeriodicProcessDeploymentStatus.RetryingDeploy: PeriodicProcessDeploymentStatus) + } + ) - override def findProcessData( - id: PeriodicProcessDeploymentId - ): Action[PeriodicProcessDeployment[WithCanonicalProcess]] = { - (PeriodicProcessesWithJson join PeriodicProcessDeployments on (_.id === _.periodicProcessId)) - .filter { case (_, deployment) => deployment.id === id } - .result - .head - .map((PeriodicProcessesRepository.createPeriodicProcessDeployment _).tupled) + private def findProcesses( + query: Query[ + (PeriodicProcessesWithoutInputConfigJsonTable, PeriodicProcessDeploymentsTable), + (PeriodicProcessEntityWithoutInputConfigJson, PeriodicProcessDeploymentEntity), + Seq + ] + ) = { + query.result + .map(_.map { case (periodicProcess, periodicDeployment) => + PeriodicProcessesRepository.createPeriodicProcessDeployment( + periodicProcess, + periodicDeployment, + ) + }) } - override def findProcessData(processName: ProcessName): Action[Seq[PeriodicProcess[WithCanonicalProcess]]] = { - PeriodicProcessesWithJson - .filter(p => p.active === true && p.processName === processName) - .result - .map(_.map(PeriodicProcessesRepository.createPeriodicProcessWithJson)) - } + override def findProcessData(id: PeriodicProcessDeploymentId): Action[PeriodicProcessDeployment] = + findProcesses( + (PeriodicProcessesWithoutInputConfig join PeriodicProcessDeployments on (_.id === _.periodicProcessId)) + .filter { case (_, deployment) => deployment.id === id } + ).map(_.head) override def markDeployed(id: PeriodicProcessDeploymentId): Action[Unit] = { val q = for { @@ -314,9 +308,9 @@ class SlickPeriodicProcessesRepository( } override def findActiveSchedulesForProcessesHavingDeploymentWithMatchingStatus( - expectedDeploymentStatuses: Set[PeriodicProcessDeploymentStatus] + expectedDeploymentStatuses: Set[PeriodicProcessDeploymentStatus], ): Action[SchedulesState] = { - val processesHavingDeploymentsWithMatchingStatus = PeriodicProcessesWithoutJson.filter(p => + val processesHavingDeploymentsWithMatchingStatus = PeriodicProcessesWithoutInputConfig.filter(p => p.active && PeriodicProcessDeployments .filter(d => d.periodicProcessId === p.id && d.status.inSet(expectedDeploymentStatuses)) @@ -324,7 +318,7 @@ class SlickPeriodicProcessesRepository( ) getLatestDeploymentsForEachSchedule( processesHavingDeploymentsWithMatchingStatus, - deploymentsPerScheduleMaxCount = 1 + deploymentsPerScheduleMaxCount = 1, ).map(schedulesForProcessNames => SchedulesState( schedulesForProcessNames.values.map(_.schedules).foldLeft(Map.empty[ScheduleId, ScheduleData])(_ ++ _) @@ -334,26 +328,27 @@ class SlickPeriodicProcessesRepository( override def getLatestDeploymentsForActiveSchedules( processName: ProcessName, - deploymentsPerScheduleMaxCount: Int + deploymentsPerScheduleMaxCount: Int, ): Action[SchedulesState] = { - val activeProcessesQuery = PeriodicProcessesWithoutJson.filter(p => p.processName === processName && p.active) + val activeProcessesQuery = + PeriodicProcessesWithoutInputConfig.filter(p => p.processName === processName && p.active) getLatestDeploymentsForEachSchedule(activeProcessesQuery, deploymentsPerScheduleMaxCount) .map(_.getOrElse(processName, SchedulesState(Map.empty))) } override def getLatestDeploymentsForActiveSchedules( - deploymentsPerScheduleMaxCount: Int + deploymentsPerScheduleMaxCount: Int, ): Action[Map[ProcessName, SchedulesState]] = { - val activeProcessesQuery = PeriodicProcessesWithoutJson.filter(_.active) + val activeProcessesQuery = PeriodicProcessesWithoutInputConfig.filter(_.active) getLatestDeploymentsForEachSchedule(activeProcessesQuery, deploymentsPerScheduleMaxCount) } override def getLatestDeploymentsForLatestInactiveSchedules( processName: ProcessName, inactiveProcessesMaxCount: Int, - deploymentsPerScheduleMaxCount: Int + deploymentsPerScheduleMaxCount: Int, ): Action[SchedulesState] = { - val filteredProcessesQuery = PeriodicProcessesWithoutJson + val filteredProcessesQuery = PeriodicProcessesWithoutInputConfig .filter(p => p.processName === processName && !p.active) .sortBy(_.createdAt.desc) .take(inactiveProcessesMaxCount) @@ -363,9 +358,9 @@ class SlickPeriodicProcessesRepository( override def getLatestDeploymentsForLatestInactiveSchedules( inactiveProcessesMaxCount: Int, - deploymentsPerScheduleMaxCount: Int + deploymentsPerScheduleMaxCount: Int, ): Action[Map[ProcessName, SchedulesState]] = { - val filteredProcessesQuery = PeriodicProcessesWithoutJson + val filteredProcessesQuery = PeriodicProcessesWithoutInputConfig .filter(!_.active) .sortBy(_.createdAt.desc) .take(inactiveProcessesMaxCount) @@ -373,8 +368,12 @@ class SlickPeriodicProcessesRepository( } private def getLatestDeploymentsForEachSchedule( - periodicProcessesQuery: Query[PeriodicProcessWithoutJson, PeriodicProcessEntityWithoutJson, Seq], - deploymentsPerScheduleMaxCount: Int + periodicProcessesQuery: Query[ + PeriodicProcessesWithoutInputConfigJsonTable, + PeriodicProcessEntityWithoutInputConfigJson, + Seq + ], + deploymentsPerScheduleMaxCount: Int, ): Action[Map[ProcessName, SchedulesState]] = { val filteredPeriodicProcessQuery = periodicProcessesQuery.filter(p => p.processingType === processingType) val latestDeploymentsForSchedules = profile match { @@ -387,9 +386,13 @@ class SlickPeriodicProcessesRepository( } private def getLatestDeploymentsForEachSchedulePostgres( - periodicProcessesQuery: Query[PeriodicProcessWithoutJson, PeriodicProcessEntityWithoutJson, Seq], + periodicProcessesQuery: Query[ + PeriodicProcessesWithoutInputConfigJsonTable, + PeriodicProcessEntityWithoutInputConfigJson, + Seq + ], deploymentsPerScheduleMaxCount: Int - ): Action[Seq[(PeriodicProcessEntityWithoutJson, PeriodicProcessDeploymentEntity)]] = { + ): Action[Seq[(PeriodicProcessEntity, PeriodicProcessDeploymentEntity)]] = { // To effectively limit deployments to given count for each schedule in one query, we use window functions in slick import ExPostgresProfile.api._ import com.github.tminglei.slickpg.window.PgWindowFuncSupport.WindowFunctions._ @@ -421,9 +424,13 @@ class SlickPeriodicProcessesRepository( // If we decided to support more databases, we should consider some optimization like extracting periodic_schedule table // with foreign key to periodic_process and with schedule_name column - it would reduce number of queries private def getLatestDeploymentsForEachScheduleJdbcGeneric( - periodicProcessesQuery: Query[PeriodicProcessWithoutJson, PeriodicProcessEntityWithoutJson, Seq], + periodicProcessesQuery: Query[ + PeriodicProcessesWithoutInputConfigJsonTable, + PeriodicProcessEntityWithoutInputConfigJson, + Seq + ], deploymentsPerScheduleMaxCount: Int - ): Action[Seq[(PeriodicProcessEntityWithoutJson, PeriodicProcessDeploymentEntity)]] = { + ): Action[Seq[(PeriodicProcessEntity, PeriodicProcessDeploymentEntity)]] = { // It is debug instead of warn to not bloast logs when e.g. for some reasons is used hsql under the hood logger.debug( "WARN: Using not optimized version of getLatestDeploymentsForEachSchedule that not uses window functions" @@ -463,7 +470,7 @@ class SlickPeriodicProcessesRepository( scheduleName: ScheduleName, runAt: LocalDateTime, deployMaxRetries: Int - ): Action[PeriodicProcessDeployment[WithCanonicalProcess]] = { + ): Action[PeriodicProcessDeployment] = { val deploymentEntity = PeriodicProcessDeploymentEntity( id = PeriodicProcessDeploymentId(-1), periodicProcessId = id, @@ -483,19 +490,26 @@ class SlickPeriodicProcessesRepository( override def markInactive(processId: PeriodicProcessId): Action[Unit] = { val q = for { - p <- PeriodicProcessesWithoutJson if p.id === processId + p <- PeriodicProcessesWithoutInputConfig if p.id === processId } yield p.active val update = q.update(false) update.map(_ => ()) } - private def activePeriodicProcessWithDeploymentQuery = { - (PeriodicProcessesWithJson.filter(p => p.active === true && p.processingType === processingType) + def fetchInputConfigDuringExecutionJson(processName: ProcessName, versionId: VersionId): Action[Option[String]] = + PeriodicProcessesWithInputConfig + .filter(p => p.processName === processName && p.processVersionId === versionId) + .map(_.inputConfigDuringExecutionJson) + .result + .headOption + + private def activePeriodicProcessWithDeploymentQuery(processingType: String) = { + (PeriodicProcessesWithoutInputConfig.filter(p => p.active === true && p.processingType === processingType) join PeriodicProcessDeployments on (_.id === _.periodicProcessId)) } private def toSchedulesState( - list: Seq[(PeriodicProcessEntityWithoutJson, PeriodicProcessDeploymentEntity)] + list: Seq[(PeriodicProcessEntity, PeriodicProcessDeploymentEntity)] ): Map[ProcessName, SchedulesState] = { list .groupBy(_._1.processName) @@ -503,46 +517,42 @@ class SlickPeriodicProcessesRepository( } private def toSchedulesStateForSinglePeriodicProcess( - list: Seq[(PeriodicProcessEntityWithoutJson, PeriodicProcessDeploymentEntity)] + list: Seq[(PeriodicProcessEntity, PeriodicProcessDeploymentEntity)] ): SchedulesState = { SchedulesState( list .map { case (process, deployment) => val scheduleId = ScheduleId(process.id, ScheduleName(deployment.scheduleName)) val scheduleData = (scheduleId, process) - val scheduleDeployment = ScheduleDeploymentData(deployment) + val scheduleDeployment = scheduleDeploymentData(deployment) (scheduleData, scheduleDeployment) } .toList .toGroupedMap .toList .map { case ((scheduleId, process), deployments) => - scheduleId -> ScheduleData(createPeriodicProcessWithoutJson(process), deployments) + scheduleId -> ScheduleData(createPeriodicProcess(process), deployments) } .toMap ) } -} - -//Copied from designer/server. -object DBIOActionInstances { - - type DB[A] = DBIOAction[A, NoStream, Effect.All] - - implicit def dbMonad(implicit ec: ExecutionContext): Monad[DB] = new Monad[DB] { - - override def pure[A](x: A) = DBIO.successful(x) - - override def flatMap[A, B](fa: DB[A])(f: (A) => DB[B]) = fa.flatMap(f) - - // this is *not* tail recursive - override def tailRecM[A, B](a: A)(f: (A) => DB[Either[A, B]]): DB[B] = - f(a).flatMap { - case Right(r) => pure(r) - case Left(l) => tailRecM(l)(f) - } - + private def scheduleDeploymentData(deployment: PeriodicProcessDeploymentEntity): ScheduleDeploymentData = { + ScheduleDeploymentData( + deployment.id, + deployment.createdAt, + deployment.runAt, + deployment.deployedAt, + deployment.retriesLeft, + deployment.nextRetryAt, + PeriodicProcessesRepository.createPeriodicDeploymentState(deployment) + ) } + override def fetchCanonicalProcessWithVersion( + processName: ProcessName, + versionId: VersionId + ): Future[Option[(CanonicalProcess, ProcessVersion)]] = + fetchingProcessRepository.getCanonicalProcessWithVersion(processName, versionId)(NussknackerInternalUser.instance) + } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioActionRepository.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioActionRepository.scala index 68aecc26a42..bf048ea9bf4 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioActionRepository.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/process/repository/ScenarioActionRepository.scala @@ -32,7 +32,7 @@ import scala.concurrent.ExecutionContext // 2. At the moment, the old ScenarioActionRepository // - handles those activities, which underlying operations may be long and may be in progress // 3. Eventually, the new ScenarioActivityRepository should be aware of the state of the underlying operation, and should replace this repository -trait ScenarioActionRepository extends LockableTable { +trait ScenarioActionRepository extends ScenarioActionReadOnlyRepository with LockableTable { def addInstantAction( processId: ProcessId, @@ -80,6 +80,10 @@ trait ScenarioActionRepository extends LockableTable { def deleteInProgressActions(): DB[Unit] +} + +trait ScenarioActionReadOnlyRepository extends LockableTable { + def getInProgressActionNames(processId: ProcessId): DB[Set[ScenarioActionName]] def getInProgressActionNames( @@ -110,10 +114,11 @@ trait ScenarioActionRepository extends LockableTable { } class DbScenarioActionRepository private ( - protected val dbRef: DbRef, + override protected val dbRef: DbRef, buildInfos: ProcessingTypeDataProvider[Map[String, String], _] )(override implicit val executionContext: ExecutionContext) - extends DbioRepository + extends DbScenarioActionReadOnlyRepository(dbRef) + with DbioRepository with NuTables with DbLockableTable with ScenarioActionRepository @@ -340,6 +345,42 @@ class DbScenarioActionRepository private ( } yield updateCount == 1 } + override def deleteInProgressActions(): DB[Unit] = { + run(scenarioActivityTable.filter(_.state === ProcessActionState.InProgress).delete.map(_ => ())) + } + + private def activityId(actionId: ProcessActionId) = + ScenarioActivityId(actionId.value) + +} + +object DbScenarioActionRepository { + + def create(dbRef: DbRef, buildInfos: ProcessingTypeDataProvider[Map[String, String], _])( + implicit executionContext: ExecutionContext, + ): ScenarioActionRepository = { + new ScenarioActionRepositoryAuditLogDecorator( + new DbScenarioActionRepository(dbRef, buildInfos) + ) + } + +} + +class DbScenarioActionReadOnlyRepository( + protected val dbRef: DbRef, +)(override implicit val executionContext: ExecutionContext) + extends DbioRepository + with NuTables + with DbLockableTable + with ScenarioActionReadOnlyRepository + with LazyLogging { + + import profile.api._ + + override type ENTITY = ScenarioActivityEntityFactory#ScenarioActivityEntity + + override protected def table: TableQuery[ScenarioActivityEntityFactory#ScenarioActivityEntity] = scenarioActivityTable + override def getInProgressActionNames(processId: ProcessId): DB[Set[ScenarioActionName]] = { val query = scenarioActivityTable .filter(action => action.scenarioId === processId && action.state === ProcessActionState.InProgress) @@ -391,10 +432,6 @@ class DbScenarioActionRepository private ( ) } - override def deleteInProgressActions(): DB[Unit] = { - run(scenarioActivityTable.filter(_.state === ProcessActionState.InProgress).delete.map(_ => ())) - } - override def getLastActionPerProcess( actionState: Set[ProcessActionState], actionNamesOpt: Option[Set[ScenarioActionName]] @@ -456,7 +493,7 @@ class DbScenarioActionRepository private ( ) } - private def toFinishedProcessAction( + protected def toFinishedProcessAction( activityEntity: ScenarioActivityEntityData ): Option[ProcessAction] = actionName(activityEntity.activityType).flatMap { actionName => (for { @@ -486,10 +523,7 @@ class DbScenarioActionRepository private ( }.toOption } - private def activityId(actionId: ProcessActionId) = - ScenarioActivityId(actionId.value) - - private def actionName(activityType: ScenarioActivityType): Option[ScenarioActionName] = { + protected def actionName(activityType: ScenarioActivityType): Option[ScenarioActionName] = { activityType match { case ScenarioActivityType.ScenarioCreated => None @@ -553,14 +587,12 @@ class DbScenarioActionRepository private ( } -object DbScenarioActionRepository { +object DbScenarioActionReadOnlyRepository { - def create(dbRef: DbRef, buildInfos: ProcessingTypeDataProvider[Map[String, String], _])( + def create(dbRef: DbRef)( implicit executionContext: ExecutionContext, - ): ScenarioActionRepository = { - new ScenarioActionRepositoryAuditLogDecorator( - new DbScenarioActionRepository(dbRef, buildInfos) - ) + ): ScenarioActionReadOnlyRepository = { + new DbScenarioActionReadOnlyRepository(dbRef) } } diff --git a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala index 4faadc146f0..1b3ec275c25 100644 --- a/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala +++ b/designer/server/src/main/scala/pl/touk/nussknacker/ui/server/AkkaHttpBasedRouteProvider.scala @@ -65,6 +65,8 @@ import pl.touk.nussknacker.ui.process.newdeployment.synchronize.{ DeploymentsStatusesSynchronizer } import pl.touk.nussknacker.ui.process.newdeployment.{DeploymentRepository, DeploymentService} +import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData +import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData.SchedulingForProcessingType import pl.touk.nussknacker.ui.process.processingtype.{ModelClassLoaderProvider, ProcessingTypeData} import pl.touk.nussknacker.ui.process.processingtype.loader.ProcessingTypeDataLoader import pl.touk.nussknacker.ui.process.processingtype.provider.ReloadableProcessingTypeDataProvider @@ -738,7 +740,8 @@ class AkkaHttpBasedRouteProvider( sttpBackend, _ ), - modelClassLoaderProvider + modelClassLoaderProvider, + Some(dbRef), ) val loadAndNotifyIO = laodProcessingTypeDataIO .map { state => diff --git a/designer/server/src/test/scala/db/migration/V1_057__MigrateActionsAndCommentsToScenarioActivities.scala b/designer/server/src/test/scala/db/migration/V1_057__MigrateActionsAndCommentsToScenarioActivities.scala index 38aaa699fb2..6fc518f7cd2 100644 --- a/designer/server/src/test/scala/db/migration/V1_057__MigrateActionsAndCommentsToScenarioActivities.scala +++ b/designer/server/src/test/scala/db/migration/V1_057__MigrateActionsAndCommentsToScenarioActivities.scala @@ -8,7 +8,6 @@ import db.migration.V1_057__MigrateActionsAndCommentsToScenarioActivitiesDefinit import io.circe.syntax.EncoderOps import org.scalatest.freespec.AnyFreeSpecLike import org.scalatest.matchers.should.Matchers -import pl.touk.nussknacker.engine.api.deployment.ScenarioComment.WithContent import pl.touk.nussknacker.engine.api.deployment._ import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} import pl.touk.nussknacker.engine.api.{MetaData, ProcessAdditionalFields, RequestResponseMetaData} diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/db/DbTesting.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/db/DbTesting.scala index fbc1af4f450..ce16e649949 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/db/DbTesting.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/db/DbTesting.scala @@ -93,6 +93,8 @@ trait DbTesting extends BeforeAndAfterEach with BeforeAndAfterAll { session.prepareStatement("""delete from "environments"""").execute() session.prepareStatement("""delete from "processes"""").execute() session.prepareStatement("""delete from "fingerprints"""").execute() + session.prepareStatement("""delete from "periodic_scenarios"""").execute() + session.prepareStatement("""delete from "periodic_scenario_deployments"""").execute() } } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala index 11600e5b9ed..c662bc0c32a 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/base/it/NuResourcesTest.scala @@ -46,6 +46,7 @@ import pl.touk.nussknacker.ui.process._ import pl.touk.nussknacker.ui.process.deployment._ import pl.touk.nussknacker.ui.process.fragment.DefaultFragmentRepository import pl.touk.nussknacker.ui.process.marshall.CanonicalProcessConverter +import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData.SchedulingForProcessingType import pl.touk.nussknacker.ui.process.processingtype._ import pl.touk.nussknacker.ui.process.processingtype.loader.ProcessingTypesConfigBasedProcessingTypeDataLoader import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider @@ -143,6 +144,7 @@ trait NuResourcesTest Streaming.stringify, modelData, deploymentManagerProvider, + SchedulingForProcessingType.NotAvailable, deploymentManagerDependencies, deploymentManagerProvider.defaultEngineSetupName, processingTypeConfig.deploymentConfig, @@ -160,7 +162,8 @@ trait NuResourcesTest .loadProcessingTypeData( _ => modelDependencies, _ => deploymentManagerDependencies, - modelClassLoaderProvider + modelClassLoaderProvider, + Some(testDbRef), ) .unsafeRunSync() ) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockDeploymentManager.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockDeploymentManager.scala index 4cf5180d00e..7f2a7b58edf 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockDeploymentManager.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockDeploymentManager.scala @@ -1,11 +1,11 @@ package pl.touk.nussknacker.test.mock -import _root_.sttp.client3.testing.SttpBackendStub import akka.actor.ActorSystem import cats.data.Validated.valid import cats.data.ValidatedNel import com.google.common.collect.LinkedHashMultimap import com.typesafe.config.Config +import sttp.client3.testing.SttpBackendStub import pl.touk.nussknacker.engine._ import pl.touk.nussknacker.engine.api.deployment._ import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus @@ -264,6 +264,7 @@ class MockDeploymentManager( override def stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport = NoStateQueryForAllScenariosSupport + override def schedulingSupport: SchedulingSupport = NoSchedulingSupport } class MockManagerProvider(deploymentManager: DeploymentManager = new MockDeploymentManager()) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockFetchingProcessRepository.scala b/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockFetchingProcessRepository.scala index b027c7317cf..cd11115ea55 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockFetchingProcessRepository.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/test/mock/MockFetchingProcessRepository.scala @@ -1,6 +1,8 @@ package pl.touk.nussknacker.test.mock +import cats.data.OptionT import cats.instances.future._ +import pl.touk.nussknacker.engine.api.ProcessVersion import pl.touk.nussknacker.engine.api.deployment.ScenarioActionName import pl.touk.nussknacker.engine.api.graph.ScenarioGraph import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessIdWithName, ProcessName, VersionId} @@ -43,6 +45,19 @@ class MockFetchingProcessRepository private ( extends FetchingProcessRepository[Future] with BasicRepository { + override def getCanonicalProcessWithVersion(processName: ProcessName, versionId: VersionId)( + implicit user: LoggedUser + ): Future[Option[(CanonicalProcess, ProcessVersion)]] = { + val result = for { + processId <- OptionT(fetchProcessId(processName)) + details <- OptionT(fetchProcessDetailsForId[CanonicalProcess](processId, versionId)) + } yield ( + details.json, + details.toEngineProcessVersion, + ) + result.value + } + override def fetchLatestProcessesDetails[PS: ScenarioShapeFetchStrategy]( q: ScenarioQuery )(implicit loggedUser: LoggedUser, ec: ExecutionContext): Future[List[ScenarioWithDetailsEntity[PS]]] = @@ -91,8 +106,8 @@ class MockFetchingProcessRepository private ( val shapeStrategy: ScenarioShapeFetchStrategy[PS] = implicitly[ScenarioShapeFetchStrategy[PS]] shapeStrategy match { - case NotFetch => process.copy(json = ().asInstanceOf[PS]) - case FetchCanonical => process.asInstanceOf[ScenarioWithDetailsEntity[PS]] + case NotFetch => process.copy(json = ()) + case FetchCanonical => process case FetchScenarioGraph => process .mapScenario(canonical => CanonicalProcessConverter.toScenarioGraph(canonical)) diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/definition/component/DefaultComponentServiceSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/definition/component/DefaultComponentServiceSpec.scala index 2e9b54cd7c7..43e0f0ef173 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/definition/component/DefaultComponentServiceSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/definition/component/DefaultComponentServiceSpec.scala @@ -41,6 +41,7 @@ import pl.touk.nussknacker.ui.definition.component.ComponentTestProcessData._ import pl.touk.nussknacker.ui.definition.component.DynamicComponentProvider._ import pl.touk.nussknacker.ui.process.DBProcessService import pl.touk.nussknacker.ui.process.fragment.DefaultFragmentRepository +import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData.SchedulingForProcessingType import pl.touk.nussknacker.ui.process.processingtype.loader.ProcessingTypeDataLoader import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.process.processingtype.{ProcessingTypeData, ScenarioParametersService} @@ -854,6 +855,7 @@ class DefaultComponentServiceSpec processingType, modelData, new MockManagerProvider, + SchedulingForProcessingType.NotAvailable, TestFactory.deploymentManagerDependencies, EngineSetupName("Mock"), deploymentConfig = ConfigFactory.empty(), diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/ProcessStateDefinitionServiceSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/ProcessStateDefinitionServiceSpec.scala index ab0d8f6a72e..78ccd06c3c2 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/ProcessStateDefinitionServiceSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/ProcessStateDefinitionServiceSpec.scala @@ -15,6 +15,7 @@ import pl.touk.nussknacker.security.Permission import pl.touk.nussknacker.test.mock.{MockDeploymentManager, MockManagerProvider} import pl.touk.nussknacker.test.utils.domain.TestFactory import pl.touk.nussknacker.test.utils.domain.TestFactory.modelDependencies +import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData.SchedulingForProcessingType import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.process.processingtype.{ProcessingTypeData, ValueWithRestriction} import pl.touk.nussknacker.ui.security.api.{AdminUser, CommonUser, LoggedUser} @@ -196,6 +197,7 @@ class ProcessStateDefinitionServiceSpec extends AnyFunSuite with Matchers { override def processStateDefinitionManager: ProcessStateDefinitionManager = stateDefinitionManager } ), + SchedulingForProcessingType.NotAvailable, TestFactory.deploymentManagerDependencies, deploymentConfig = ConfigFactory.empty(), engineSetupName = EngineSetupName("mock"), diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessServiceIntegrationTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessServiceIntegrationTest.scala similarity index 75% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessServiceIntegrationTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessServiceIntegrationTest.scala index 12b2daa5c41..7737de6761d 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessServiceIntegrationTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessServiceIntegrationTest.scala @@ -1,32 +1,49 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import com.cronutils.builder.CronBuilder import com.cronutils.model.CronType import com.cronutils.model.definition.CronDefinitionBuilder import com.cronutils.model.field.expression.FieldExpressionFactory.{on, questionMark} -import com.dimafeng.testcontainers.{ForAllTestContainer, PostgreSQLContainer} import com.typesafe.config.{Config, ConfigFactory} import com.typesafe.scalalogging.LazyLogging +import db.util.DBIOActionInstances.DB import org.scalatest.LoneElement._ import org.scalatest.OptionValues import org.scalatest.concurrent.ScalaFutures import org.scalatest.exceptions.TestFailedException import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers -import org.testcontainers.utility.DockerImageName import pl.touk.nussknacker.engine.api.deployment._ +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.{ + ProcessConfigEnricher, + ScheduledProcessEvent, + ScheduledProcessListener +} import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus.ProblemStateStatus -import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessIdWithName, ProcessName} +import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessIdWithName, ProcessName, VersionId} import pl.touk.nussknacker.engine.api.{MetaData, ProcessVersion, StreamMetaData} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.PeriodicProcessService.PeriodicProcessStatus -import pl.touk.nussknacker.engine.management.periodic.db.{DbInitializer, SlickPeriodicProcessesRepository} -import pl.touk.nussknacker.engine.management.periodic.model._ -import pl.touk.nussknacker.engine.management.periodic.service._ import pl.touk.nussknacker.test.PatientScalaFutures +import pl.touk.nussknacker.test.base.db.WithPostgresDbTesting +import pl.touk.nussknacker.test.base.it.WithClock +import pl.touk.nussknacker.test.utils.domain.TestFactory +import pl.touk.nussknacker.test.utils.domain.TestFactory.newWriteProcessRepository +import pl.touk.nussknacker.test.utils.scalas.DBIOActionValues +import pl.touk.nussknacker.ui.process.periodic.PeriodicProcessService.PeriodicProcessStatus +import pl.touk.nussknacker.ui.process.periodic.flink.{DeploymentManagerStub, ScheduledExecutionPerformerStub} +import pl.touk.nussknacker.ui.process.periodic.legacy.db.{LegacyDbInitializer, SlickLegacyPeriodicProcessesRepository} +import pl.touk.nussknacker.ui.process.periodic.model._ +import pl.touk.nussknacker.ui.process.repository.ProcessRepository.CreateProcessAction +import pl.touk.nussknacker.ui.process.repository.{ + DBIOActionRunner, + DBProcessRepository, + PeriodicProcessesRepository, + SlickPeriodicProcessesRepository +} +import pl.touk.nussknacker.ui.security.api.AdminUser import slick.jdbc -import slick.jdbc.{JdbcBackend, JdbcProfile} +import slick.jdbc.JdbcProfile import java.time._ import java.time.temporal.ChronoUnit @@ -42,21 +59,21 @@ class PeriodicProcessServiceIntegrationTest with OptionValues with ScalaFutures with PatientScalaFutures - with ForAllTestContainer - with LazyLogging { - - override val container: PostgreSQLContainer = PostgreSQLContainer(DockerImageName.parse("postgres:11.2")) + with WithPostgresDbTesting + with LazyLogging + with WithClock + with DBIOActionValues { import scala.concurrent.ExecutionContext.Implicits.global + override protected def dbioRunner: DBIOActionRunner = new DBIOActionRunner(testDbRef) + implicit val freshnessPolicy: DataFreshnessPolicy = DataFreshnessPolicy.Fresh private val processingType = "testProcessingType" private val processName = ProcessName("test") - private val processIdWithName = ProcessIdWithName(ProcessId(1), processName) - private val sampleProcess = CanonicalProcess(MetaData(processName.value, StreamMetaData()), Nil) private val startTime = Instant.parse("2021-04-06T13:18:00Z") @@ -74,6 +91,9 @@ class PeriodicProcessServiceIntegrationTest executionConfig: PeriodicExecutionConfig = PeriodicExecutionConfig(), maxFetchedPeriodicScenarioActivities: Option[Int] = None, )(testCode: Fixture => Any): Unit = { + + val fetchingProcessRepository = TestFactory.newFutureFetchingScenarioRepository(testDbRef) + val postgresConfig = ConfigFactory.parseMap( Map( "user" -> container.username, @@ -92,43 +112,73 @@ class PeriodicProcessServiceIntegrationTest ).asJava ) - def runTestCodeWithDbConfig(config: Config) = { - val (db: jdbc.JdbcBackend.DatabaseDef, dbProfile: JdbcProfile) = DbInitializer.init(config) + def runWithLegacyRepository(dbConfig: Config): Unit = { + val (db: jdbc.JdbcBackend.DatabaseDef, dbProfile: JdbcProfile) = LegacyDbInitializer.init(dbConfig) + val creator = (processingType: String, currentTime: Instant) => + new SlickLegacyPeriodicProcessesRepository( + processingType, + db, + dbProfile, + fixedClock(currentTime), + fetchingProcessRepository, + ) try { testCode( - new Fixture(db, dbProfile, deploymentRetryConfig, executionConfig, maxFetchedPeriodicScenarioActivities) + new Fixture(creator, deploymentRetryConfig, executionConfig, maxFetchedPeriodicScenarioActivities) ) } finally { db.close() } } - logger.debug("Running test with hsql") - runTestCodeWithDbConfig(hsqlConfig) - logger.debug("Running test with postgres") - runTestCodeWithDbConfig(postgresConfig) + + def runTestCodeWithNuDb(): Unit = { + val creator = (processingType: String, currentTime: Instant) => + new SlickPeriodicProcessesRepository( + processingType, + testDbRef.db, + testDbRef.profile, + fixedClock(currentTime), + fetchingProcessRepository, + ) + testCode( + new Fixture(creator, deploymentRetryConfig, executionConfig, maxFetchedPeriodicScenarioActivities) + ) + } + + def testHeader(str: String) = "\n\n" + "*" * 100 + s"\n***** $str\n" + "*" * 100 + "\n" + logger.info(testHeader("Running test with legacy hsql-based repository")) + runWithLegacyRepository(hsqlConfig) + cleanDB() + logger.info(testHeader("Running test with legacy postgres-based repository")) + runWithLegacyRepository(postgresConfig) + cleanDB() + logger.info(testHeader("Running test with Nu database")) + runTestCodeWithNuDb() + cleanDB() } class Fixture( - db: JdbcBackend.DatabaseDef, - dbProfile: JdbcProfile, + periodicProcessesRepositoryCreator: (String, Instant) => PeriodicProcessesRepository, deploymentRetryConfig: DeploymentRetryConfig, executionConfig: PeriodicExecutionConfig, maxFetchedPeriodicScenarioActivities: Option[Int], ) { - val delegateDeploymentManagerStub = new DeploymentManagerStub - val jarManagerStub = new JarManagerStub - val events = new ArrayBuffer[PeriodicProcessEvent]() - var failListener = false - - def periodicProcessService(currentTime: Instant, processingType: String = processingType) = + val delegateDeploymentManagerStub = new DeploymentManagerStub + val scheduledExecutionPerformerStub = new ScheduledExecutionPerformerStub + val events = new ArrayBuffer[ScheduledProcessEvent]() + var failListener = false + + def periodicProcessService( + currentTime: Instant, + processingType: String = processingType + ) = new PeriodicProcessService( delegateDeploymentManager = delegateDeploymentManagerStub, - jarManager = jarManagerStub, - scheduledProcessesRepository = - new SlickPeriodicProcessesRepository(db, dbProfile, fixedClock(currentTime), processingType), - periodicProcessListener = new PeriodicProcessListener { + scheduledExecutionPerformer = scheduledExecutionPerformerStub, + periodicProcessesRepository = periodicProcessesRepositoryCreator(processingType, currentTime), + periodicProcessListener = new ScheduledProcessListener { - override def onPeriodicProcessEvent: PartialFunction[PeriodicProcessEvent, Unit] = { + override def onScheduledProcessEvent: PartialFunction[ScheduledProcessEvent, Unit] = { case k if failListener => throw new Exception(s"$k was ordered to fail") case k => events.append(k) } @@ -141,8 +191,26 @@ class PeriodicProcessServiceIntegrationTest processConfigEnricher = ProcessConfigEnricher.identity, clock = fixedClock(currentTime), new ProcessingTypeActionServiceStub, - Map.empty + Map.empty, + ) + + def writeProcessRepository: DBProcessRepository = newWriteProcessRepository(testDbRef, clock) + + def prepareProcess(processName: ProcessName): DB[ProcessIdWithName] = { + val canonicalProcess = CanonicalProcess(MetaData(processName.value, StreamMetaData()), Nil) + val action = CreateProcessAction( + processName = processName, + category = "Category1", + canonicalProcess = canonicalProcess, + processingType = "streaming", + isFragment = false, + forwardedUserName = None ) + writeProcessRepository + .saveNewProcess(action)(AdminUser("artificialTestAdmin", "artificialTestAdmin")) + .map(_.value.processId) + .map(ProcessIdWithName(_, processName)) + } } @@ -159,12 +227,15 @@ class PeriodicProcessServiceIntegrationTest def otherProcessingTypeService = f.periodicProcessService(currentTime, processingType = "other") val otherProcessName = ProcessName("other") + val processIdWithName = f.prepareProcess(processName).dbioActionValues + val otherProcessIdWithName = f.prepareProcess(otherProcessName).dbioActionValues + service .schedule( cronEveryHour, - ProcessVersion.empty.copy(processName = processName), + ProcessVersion(VersionId(1), processIdWithName.name, processIdWithName.id, List.empty, "testUser", None), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue service @@ -172,7 +243,7 @@ class PeriodicProcessServiceIntegrationTest cronEvery30Minutes, ProcessVersion.empty.copy(processName = every30MinutesProcessName), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue service @@ -180,15 +251,15 @@ class PeriodicProcessServiceIntegrationTest cronEvery4Hours, ProcessVersion.empty.copy(processName = every4HoursProcessName), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue otherProcessingTypeService .schedule( cronEveryHour, - ProcessVersion.empty.copy(processName = otherProcessName), + ProcessVersion.empty.copy(processName = otherProcessIdWithName.name), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue @@ -196,22 +267,22 @@ class PeriodicProcessServiceIntegrationTest stateAfterSchedule should have size 1 val afterSchedule = stateAfterSchedule.firstScheduleData - afterSchedule.process.processVersion.processName shouldBe processName + afterSchedule.process.deploymentData.processName shouldBe processName afterSchedule.latestDeployments.head.state shouldBe PeriodicProcessDeploymentState( None, None, PeriodicProcessDeploymentStatus.Scheduled ) afterSchedule.latestDeployments.head.runAt shouldBe localTime(expectedScheduleTime) - service.getLatestDeploymentsForActiveSchedules(otherProcessName).futureValue shouldBe empty + service.getLatestDeploymentsForActiveSchedules(otherProcessIdWithName.name).futureValue shouldBe empty currentTime = timeToTriggerCheck val allToDeploy = service.findToBeDeployed.futureValue allToDeploy.map( - _.periodicProcess.processVersion.processName + _.periodicProcess.deploymentData.processName ) should contain only (processName, every30MinutesProcessName) - val toDeploy = allToDeploy.find(_.periodicProcess.processVersion.processName == processName).value + val toDeploy = allToDeploy.find(_.periodicProcess.deploymentData.processName == processName).value service.deploy(toDeploy).futureValue otherProcessingTypeService.deploy(otherProcessingTypeService.findToBeDeployed.futureValue.loneElement).futureValue @@ -237,7 +308,7 @@ class PeriodicProcessServiceIntegrationTest // here we check that scenarios that not fired are still on the "toDeploy" list and finished are not on the list val toDeployAfterFinish = service.findToBeDeployed.futureValue - toDeployAfterFinish.map(_.periodicProcess.processVersion.processName) should contain only every30MinutesProcessName + toDeployAfterFinish.map(_.periodicProcess.deploymentData.processName) should contain only every30MinutesProcessName service.deactivate(processName).futureValue service.getLatestDeploymentsForActiveSchedules(processName).futureValue shouldBe empty val inactiveStates = service @@ -256,7 +327,7 @@ class PeriodicProcessServiceIntegrationTest val firstActivity = activities.head.asInstanceOf[ScenarioActivity.PerformedScheduledExecution] activities shouldBe List( ScenarioActivity.PerformedScheduledExecution( - scenarioId = ScenarioId(1), + scenarioId = ScenarioId(processIdWithName.id.value), scenarioActivityId = firstActivity.scenarioActivityId, user = ScenarioUser(None, UserName("Nussknacker"), None, None), date = firstActivity.date, @@ -272,14 +343,23 @@ class PeriodicProcessServiceIntegrationTest } it should "handleFinished for all finished periodic scenarios waiting for reschedule" in withFixture() { f => - val timeToTriggerCheck = startTime.plus(2, ChronoUnit.HOURS) - var currentTime = startTime - def service = f.periodicProcessService(currentTime) + val timeToTriggerCheck = startTime.plus(2, ChronoUnit.HOURS) + var currentTime = startTime + def service = f.periodicProcessService(currentTime) + val firstProcessIdWithName = f.prepareProcess(ProcessName("first")).dbioActionValues + val secondProcessIdWithName = f.prepareProcess(ProcessName("second")).dbioActionValues service .schedule( cronEveryHour, - ProcessVersion.empty.copy(processName = ProcessName("first")), + ProcessVersion( + VersionId(1), + firstProcessIdWithName.name, + firstProcessIdWithName.id, + List.empty, + "testUser", + None + ), sampleProcess, randomProcessActionId ) @@ -287,7 +367,14 @@ class PeriodicProcessServiceIntegrationTest service .schedule( cronEveryHour, - ProcessVersion.empty.copy(processName = ProcessName("second")), + ProcessVersion( + VersionId(1), + secondProcessIdWithName.name, + secondProcessIdWithName.id, + List.empty, + "testUser", + None + ), sampleProcess, randomProcessActionId ) @@ -328,15 +415,18 @@ class PeriodicProcessServiceIntegrationTest ) { f => val timeToTriggerCheck = startTime.plus(2, ChronoUnit.HOURS) var currentTime = startTime - f.jarManagerStub.deployWithJarFuture = Future.failed(new RuntimeException("Flink deploy error")) + f.scheduledExecutionPerformerStub.deployWithJarFuture = Future.failed(new RuntimeException("Flink deploy error")) def service = f.periodicProcessService(currentTime) + + val processIdWithName = f.prepareProcess(processName).dbioActionValues + service .schedule( cronEveryHour, - ProcessVersion.empty.copy(processName = processName), + ProcessVersion(VersionId(1), processIdWithName.name, processIdWithName.id, List.empty, "testUser", None), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue @@ -357,7 +447,7 @@ class PeriodicProcessServiceIntegrationTest val firstActivity = activities.head.asInstanceOf[ScenarioActivity.PerformedScheduledExecution] activities shouldBe List( ScenarioActivity.PerformedScheduledExecution( - scenarioId = ScenarioId(1), + scenarioId = ScenarioId(processIdWithName.id.value), scenarioActivityId = firstActivity.scenarioActivityId, user = ScenarioUser(None, UserName("Nussknacker"), None, None), date = firstActivity.date, @@ -382,6 +472,9 @@ class PeriodicProcessServiceIntegrationTest val scheduleMinute5 = "scheduleMinute5" val scheduleMinute10 = "scheduleMinute10" + + val processIdWithName = f.prepareProcess(processName).dbioActionValues + service .schedule( MultipleScheduleProperty( @@ -390,9 +483,9 @@ class PeriodicProcessServiceIntegrationTest scheduleMinute10 -> CronScheduleProperty("0 10 * * * ?") ) ), - ProcessVersion.empty.copy(processName = processName), + ProcessVersion(VersionId(1), processIdWithName.name, processIdWithName.id, List.empty, "testUser", None), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue @@ -407,7 +500,7 @@ class PeriodicProcessServiceIntegrationTest ), ProcessVersion.empty.copy(processName = ProcessName("other")), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue @@ -424,7 +517,7 @@ class PeriodicProcessServiceIntegrationTest val allToDeploy = service.findToBeDeployed.futureValue allToDeploy should have length 4 - val toDeploy = allToDeploy.filter(_.periodicProcess.processVersion.processName == processName) + val toDeploy = allToDeploy.filter(_.periodicProcess.deploymentData.processName == processName) toDeploy should have length 2 toDeploy.head.runAt shouldBe localTime(expectedScheduleTime.plus(5, ChronoUnit.MINUTES)) toDeploy.head.scheduleName.value shouldBe Some(scheduleMinute5) @@ -447,6 +540,9 @@ class PeriodicProcessServiceIntegrationTest val firstSchedule = "schedule1" val secondSchedule = "schedule2" + + val processIdWithName = f.prepareProcess(processName).dbioActionValues + service .schedule( MultipleScheduleProperty( @@ -455,9 +551,9 @@ class PeriodicProcessServiceIntegrationTest secondSchedule -> CronScheduleProperty("0 5 * * * ?") ) ), - ProcessVersion.empty.copy(processName = processName), + ProcessVersion(VersionId(1), processIdWithName.name, processIdWithName.id, List.empty, "testUser", None), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue @@ -467,7 +563,7 @@ class PeriodicProcessServiceIntegrationTest toDeploy should have length 2 val deployment = toDeploy.find(_.scheduleName.value.contains(firstSchedule)).value - service.deploy(deployment) + service.deploy(deployment).futureValue f.delegateDeploymentManagerStub.setStateStatus(processName, SimpleStateStatus.Running, Some(deployment.id)) val toDeployAfterDeploy = service.findToBeDeployed.futureValue @@ -487,7 +583,7 @@ class PeriodicProcessServiceIntegrationTest } firstActivity shouldBe ScenarioActivity.PerformedScheduledExecution( - scenarioId = ScenarioId(1), + scenarioId = ScenarioId(processIdWithName.id.value), scenarioActivityId = firstActivity.scenarioActivityId, user = ScenarioUser(None, UserName("Nussknacker"), None, None), date = firstActivity.date, @@ -503,7 +599,9 @@ class PeriodicProcessServiceIntegrationTest it should "handle multiple one time schedules" in withFixture() { f => handleMultipleOneTimeSchedules(f) - def service = f.periodicProcessService(startTime) + def service = f.periodicProcessService(startTime) + val processIdWithName = ProcessIdWithName(ProcessId(1), processName) + val activities = service.getScenarioActivitiesSpecificToPeriodicProcess(processIdWithName, None).futureValue val firstActivity = activities.head.asInstanceOf[ScenarioActivity.PerformedScheduledExecution] val secondActivity = activities(1).asInstanceOf[ScenarioActivity.PerformedScheduledExecution] @@ -541,7 +639,9 @@ class PeriodicProcessServiceIntegrationTest maxFetchedPeriodicScenarioActivities = Some(1) ) { f => handleMultipleOneTimeSchedules(f) - def service = f.periodicProcessService(startTime) + def service = f.periodicProcessService(startTime) + val processIdWithName = ProcessIdWithName(ProcessId(1), processName) + val activities = service.getScenarioActivitiesSpecificToPeriodicProcess(processIdWithName, None).futureValue val firstActivity = activities.head.asInstanceOf[ScenarioActivity.PerformedScheduledExecution] activities shouldBe List( @@ -578,6 +678,9 @@ class PeriodicProcessServiceIntegrationTest val schedule1 = "schedule1" val schedule2 = "schedule2" + + val processIdWithName = f.prepareProcess(processName).dbioActionValues + service .schedule( MultipleScheduleProperty( @@ -586,9 +689,9 @@ class PeriodicProcessServiceIntegrationTest schedule2 -> CronScheduleProperty(convertDateToCron(localTime(timeToTriggerSchedule2))) ) ), - ProcessVersion.empty.copy(processName = processName), + ProcessVersion(VersionId(1), processIdWithName.name, processIdWithName.id, List.empty, "testUser", None), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue @@ -659,7 +762,6 @@ class PeriodicProcessServiceIntegrationTest .futureValue inactiveStates.latestDeploymentForSchedule(schedule1).state.status shouldBe PeriodicProcessDeploymentStatus.Finished inactiveStates.latestDeploymentForSchedule(schedule2).state.status shouldBe PeriodicProcessDeploymentStatus.Finished - } it should "handle failed event handler" in withFixture() { f => @@ -669,19 +771,21 @@ class PeriodicProcessServiceIntegrationTest def service = f.periodicProcessService(currentTime) - def tryWithFailedListener[T](action: () => Future[T]): T = { + def tryWithFailedListener[T](action: () => Future[T]): Unit = { f.failListener = true - intercept[TestFailedException](action().futureValue).getCause shouldBe a[PeriodicProcessException] + val exception = intercept[TestFailedException](action().futureValue) + exception.getCause shouldBe a[PeriodicProcessException] f.failListener = false - action().futureValue } + val processIdWithName = f.prepareProcess(processName).dbioActionValues + tryWithFailedListener { () => service.schedule( cronEveryHour, - ProcessVersion.empty.copy(processName = processName), + ProcessVersion(VersionId(1), processIdWithName.name, processIdWithName.id, List.empty, "testUser", None), sampleProcess, - randomProcessActionId + randomProcessActionId, ) } @@ -703,15 +807,17 @@ class PeriodicProcessServiceIntegrationTest val timeToTriggerCheck = startTime.plus(1, ChronoUnit.HOURS) var currentTime = startTime - f.jarManagerStub.deployWithJarFuture = Future.failed(new RuntimeException("Flink deploy error")) + f.scheduledExecutionPerformerStub.deployWithJarFuture = Future.failed(new RuntimeException("Flink deploy error")) def service = f.periodicProcessService(currentTime) + val processIdWithName = f.prepareProcess(processName).dbioActionValues + service .schedule( cronEveryHour, - ProcessVersion.empty.copy(processName = processName), + ProcessVersion(VersionId(1), processIdWithName.name, processIdWithName.id, List.empty, "testUser", None), sampleProcess, - randomProcessActionId + randomProcessActionId, ) .futureValue currentTime = timeToTriggerCheck @@ -732,7 +838,7 @@ class PeriodicProcessServiceIntegrationTest val firstActivity = activities.head.asInstanceOf[ScenarioActivity.PerformedScheduledExecution] activities shouldBe List( ScenarioActivity.PerformedScheduledExecution( - scenarioId = ScenarioId(1), + scenarioId = ScenarioId(processIdWithName.id.value), scenarioActivityId = firstActivity.scenarioActivityId, user = ScenarioUser(None, UserName("Nussknacker"), None, None), date = firstActivity.date, diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessesFetchingTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessesFetchingTest.scala similarity index 65% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessesFetchingTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessesFetchingTest.scala index a5278f94586..b296f1271ef 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessesFetchingTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/PeriodicProcessesFetchingTest.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic import org.scalatest.concurrent.ScalaFutures import org.scalatest.funsuite.AnyFunSuite @@ -9,17 +9,18 @@ import pl.touk.nussknacker.engine.api.deployment._ import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.process.ProcessName import pl.touk.nussknacker.engine.deployment.DeploymentData -import pl.touk.nussknacker.engine.management.periodic.db.InMemPeriodicProcessesRepository.getLatestDeploymentQueryCount -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentStatus -import pl.touk.nussknacker.engine.management.periodic.service.{ - DefaultAdditionalDeploymentDataProvider, - EmptyListener, - ProcessConfigEnricher -} import pl.touk.nussknacker.test.PatientScalaFutures +import pl.touk.nussknacker.ui.process.periodic.flink.{DeploymentManagerStub, ScheduledExecutionPerformerStub} +import pl.touk.nussknacker.ui.process.periodic.flink.db.InMemPeriodicProcessesRepository +import pl.touk.nussknacker.ui.process.periodic.flink.db.InMemPeriodicProcessesRepository.getLatestDeploymentQueryCount +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.{EmptyListener, ProcessConfigEnricher} +import pl.touk.nussknacker.ui.process.periodic.cron.CronSchedulePropertyExtractor +import pl.touk.nussknacker.ui.process.repository.{FetchingProcessRepository, PeriodicProcessesRepository} import java.time.Clock import java.util.UUID +import scala.concurrent.Future class PeriodicProcessesFetchingTest extends AnyFunSuite @@ -37,15 +38,16 @@ class PeriodicProcessesFetchingTest private def processName(n: Int) = ProcessName(s"test$n") class Fixture(executionConfig: PeriodicExecutionConfig = PeriodicExecutionConfig()) { - val repository = new db.InMemPeriodicProcessesRepository(processingType = "testProcessingType") - val delegateDeploymentManagerStub = new DeploymentManagerStub - val jarManagerStub = new JarManagerStub - val preparedDeploymentData = DeploymentData.withDeploymentId(UUID.randomUUID().toString) + val processingType = "testProcessingType" + val repository = new InMemPeriodicProcessesRepository(processingType) + val delegateDeploymentManagerStub = new DeploymentManagerStub + val scheduledExecutionPerformerStub = new ScheduledExecutionPerformerStub + val preparedDeploymentData = DeploymentData.withDeploymentId(UUID.randomUUID().toString) val periodicProcessService = new PeriodicProcessService( delegateDeploymentManager = delegateDeploymentManagerStub, - jarManager = jarManagerStub, - scheduledProcessesRepository = repository, + scheduledExecutionPerformer = scheduledExecutionPerformerStub, + periodicProcessesRepository = repository, periodicProcessListener = EmptyListener, additionalDeploymentDataProvider = DefaultAdditionalDeploymentDataProvider, deploymentRetryConfig = DeploymentRetryConfig(), @@ -60,7 +62,7 @@ class PeriodicProcessesFetchingTest val periodicDeploymentManager = new PeriodicDeploymentManager( delegate = delegateDeploymentManagerStub, service = periodicProcessService, - repository = repository, + periodicProcessesRepository = repository, schedulePropertyExtractor = CronSchedulePropertyExtractor(), toClose = () => () ) @@ -76,7 +78,8 @@ class PeriodicProcessesFetchingTest f.delegateDeploymentManagerStub.setEmptyStateStatus() for (i <- 1 to n) { - val deploymentId = f.repository.addActiveProcess(processName(i), PeriodicProcessDeploymentStatus.Deployed) + val deploymentId = + f.repository.addActiveProcess(processName(i), PeriodicProcessDeploymentStatus.Deployed) f.delegateDeploymentManagerStub.addStateStatus(processName(i), SimpleStateStatus.Running, Some(deploymentId)) } @@ -91,14 +94,17 @@ class PeriodicProcessesFetchingTest getLatestDeploymentQueryCount.get() shouldEqual 2 * n } - test("getStatusDetails - should perform 2 db queries for N periodic processes when fetching all at once") { + test( + "getAllProcessesStates - should perform 2 db queries for N periodic processes when fetching all at once" + ) { val f = new Fixture val n = 10 f.delegateDeploymentManagerStub.setEmptyStateStatus() for (i <- 1 to n) { - val deploymentId = f.repository.addActiveProcess(processName(i), PeriodicProcessDeploymentStatus.Deployed) + val deploymentId = + f.repository.addActiveProcess(processName(i), PeriodicProcessDeploymentStatus.Deployed) f.delegateDeploymentManagerStub.addStateStatus(processName(i), SimpleStateStatus.Running, Some(deploymentId)) } diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/CronSchedulePropertyExtractorTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/CronSchedulePropertyExtractorTest.scala similarity index 73% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/CronSchedulePropertyExtractorTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/CronSchedulePropertyExtractorTest.scala index 5874bd1bb26..4b0c326fd2e 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/CronSchedulePropertyExtractorTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/CronSchedulePropertyExtractorTest.scala @@ -1,14 +1,16 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import org.scalatest.Inside import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import pl.touk.nussknacker.engine.api.NodeId +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{ScheduleProperty => ApiScheduleProperty} import pl.touk.nussknacker.engine.api.parameter.ParameterName import pl.touk.nussknacker.engine.build.ScenarioBuilder import pl.touk.nussknacker.engine.graph.expression.Expression -import pl.touk.nussknacker.engine.management.periodic.cron.CronParameterValidator import pl.touk.nussknacker.test.{EitherValuesDetailedMessage, ValidatedValuesDetailedMessage} +import pl.touk.nussknacker.ui.process.periodic.cron.{CronParameterValidator, CronSchedulePropertyExtractor} +import pl.touk.nussknacker.ui.process.periodic.{CronScheduleProperty, MultipleScheduleProperty} class CronSchedulePropertyExtractorTest extends AnyFunSuite @@ -39,16 +41,16 @@ class CronSchedulePropertyExtractorTest test("should extract cron property") { val result = extractor(PeriodicProcessGen.buildCanonicalProcess()) - inside(result) { case Right(CronScheduleProperty(_)) => } + inside(result) { case Right(ApiScheduleProperty.CronScheduleProperty(_)) => } } test("should extract MultipleScheduleProperty") { val multipleSchedulesExpression = "{foo: '0 0 * * * ?', bar: '1 0 * * * ?'}" val result = extractor(PeriodicProcessGen.buildCanonicalProcess(multipleSchedulesExpression)) - result.rightValue shouldEqual MultipleScheduleProperty( + result.rightValue shouldEqual ApiScheduleProperty.MultipleScheduleProperty( Map( - "foo" -> CronScheduleProperty("0 0 * * * ?"), - "bar" -> CronScheduleProperty("1 0 * * * ?") + "foo" -> ApiScheduleProperty.CronScheduleProperty("0 0 * * * ?"), + "bar" -> ApiScheduleProperty.CronScheduleProperty("1 0 * * * ?") ) ) diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/CronSchedulePropertyTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/CronSchedulePropertyTest.scala similarity index 89% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/CronSchedulePropertyTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/CronSchedulePropertyTest.scala index 5fff9dcb12d..0177cd9fea3 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/CronSchedulePropertyTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/CronSchedulePropertyTest.scala @@ -1,8 +1,10 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink -import java.time.{Clock, LocalDateTime, ZoneId, ZonedDateTime} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import pl.touk.nussknacker.ui.process.periodic.CronScheduleProperty + +import java.time.{Clock, LocalDateTime, ZoneId, ZonedDateTime} class CronSchedulePropertyTest extends AnyFunSuite with Matchers { diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/DeploymentActorTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/DeploymentActorTest.scala similarity index 63% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/DeploymentActorTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/DeploymentActorTest.scala index ac646390e38..eaeed51fe9e 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/DeploymentActorTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/DeploymentActorTest.scala @@ -1,15 +1,14 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import akka.actor.{ActorRef, ActorSystem} import akka.testkit.{TestKit, TestKitBase, TestProbe} -import org.scalatest.LoneElement._ import org.scalatest.BeforeAndAfterAll +import org.scalatest.LoneElement._ import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.DeploymentActor.CheckToBeDeployed -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithCanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeployment +import pl.touk.nussknacker.ui.process.periodic.DeploymentActor +import pl.touk.nussknacker.ui.process.periodic.DeploymentActor.CheckToBeDeployed +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeployment import scala.concurrent.Future import scala.concurrent.duration._ @@ -34,11 +33,11 @@ class DeploymentActorTest extends AnyFunSuite with TestKitBase with Matchers wit } private def shouldFindToBeDeployedScenarios( - result: Future[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]] + result: Future[Seq[PeriodicProcessDeployment]] ): Unit = { val probe = TestProbe() var counter = 0 - def findToBeDeployed: Future[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]] = { + def findToBeDeployed: Future[Seq[PeriodicProcessDeployment]] = { counter += 1 probe.ref ! s"invoked $counter" result @@ -55,14 +54,14 @@ class DeploymentActorTest extends AnyFunSuite with TestKitBase with Matchers wit } test("should deploy found scenario") { - val probe = TestProbe() - val waitingDeployment = PeriodicProcessDeploymentGen() - var toBeDeployed: Seq[PeriodicProcessDeployment[WithCanonicalProcess]] = Seq(waitingDeployment) - var actor: ActorRef = null - def findToBeDeployed: Future[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]] = { + val probe = TestProbe() + val waitingDeployment = PeriodicProcessDeploymentGen() + var toBeDeployed: Seq[PeriodicProcessDeployment] = Seq(waitingDeployment) + var actor: ActorRef = null + def findToBeDeployed: Future[Seq[PeriodicProcessDeployment]] = { Future.successful(toBeDeployed) } - def deploy(deployment: PeriodicProcessDeployment[WithCanonicalProcess]): Future[Unit] = { + def deploy(deployment: PeriodicProcessDeployment): Future[Unit] = { probe.ref ! deployment // Simulate periodic check for waiting scenarios while deploying a scenario. actor ! CheckToBeDeployed diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/DeploymentManagerStub.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/DeploymentManagerStub.scala similarity index 93% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/DeploymentManagerStub.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/DeploymentManagerStub.scala index e424331f837..8e59980e28d 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/DeploymentManagerStub.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/DeploymentManagerStub.scala @@ -1,11 +1,11 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import pl.touk.nussknacker.engine.api.deployment._ import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.process.{ProcessIdWithName, ProcessName, VersionId} import pl.touk.nussknacker.engine.deployment.{DeploymentId, ExternalDeploymentId} -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentId import pl.touk.nussknacker.engine.testing.StubbingCommands +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentId import scala.concurrent.Future @@ -82,6 +82,8 @@ class DeploymentManagerStub extends BaseDeploymentManager with StubbingCommands override def deploymentSynchronisationSupport: DeploymentSynchronisationSupport = NoDeploymentSynchronisationSupport + override def schedulingSupport: SchedulingSupport = NoSchedulingSupport + override def stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport = new StateQueryForAllScenariosSupported { diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/FlinkClientStub.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/FlinkClientStub.scala similarity index 96% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/FlinkClientStub.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/FlinkClientStub.scala index 5c386e88208..b1b260d1a7d 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/FlinkClientStub.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/FlinkClientStub.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import org.apache.flink.configuration.Configuration import pl.touk.nussknacker.engine.api.deployment.{DataFreshnessPolicy, SavepointResult, WithDataFreshnessStatus} diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicDeploymentManagerTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicDeploymentManagerTest.scala similarity index 94% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicDeploymentManagerTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicDeploymentManagerTest.scala index 82f1c575b0b..eb968cee662 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicDeploymentManagerTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicDeploymentManagerTest.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import org.scalatest.concurrent.ScalaFutures import org.scalatest.funsuite.AnyFunSuite @@ -13,15 +13,14 @@ import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessIdWithName, Pro import pl.touk.nussknacker.engine.api.{MetaData, ProcessVersion, StreamMetaData} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.engine.deployment.{DeploymentData, User} -import pl.touk.nussknacker.engine.management.periodic.PeriodicProcessService.PeriodicProcessStatus -import pl.touk.nussknacker.engine.management.periodic.PeriodicStateStatus.{ScheduledStatus, WaitingForScheduleStatus} -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentStatus -import pl.touk.nussknacker.engine.management.periodic.service.{ - DefaultAdditionalDeploymentDataProvider, - EmptyListener, - ProcessConfigEnricher -} import pl.touk.nussknacker.test.PatientScalaFutures +import pl.touk.nussknacker.ui.process.periodic.PeriodicProcessService.PeriodicProcessStatus +import pl.touk.nussknacker.ui.process.periodic.PeriodicStateStatus.{ScheduledStatus, WaitingForScheduleStatus} +import pl.touk.nussknacker.ui.process.periodic._ +import pl.touk.nussknacker.ui.process.periodic.flink.db.InMemPeriodicProcessesRepository +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.{EmptyListener, ProcessConfigEnricher} +import pl.touk.nussknacker.ui.process.periodic.cron.CronSchedulePropertyExtractor import java.time.{Clock, LocalDateTime, ZoneOffset} import java.util.UUID @@ -59,15 +58,15 @@ class PeriodicDeploymentManagerTest ) class Fixture(executionConfig: PeriodicExecutionConfig = PeriodicExecutionConfig()) { - val repository = new db.InMemPeriodicProcessesRepository(processingType = "testProcessingType") - val delegateDeploymentManagerStub = new DeploymentManagerStub - val jarManagerStub = new JarManagerStub - val preparedDeploymentData = DeploymentData.withDeploymentId(UUID.randomUUID().toString) + val repository = new InMemPeriodicProcessesRepository(processingType = "testProcessingType") + val delegateDeploymentManagerStub = new DeploymentManagerStub + val scheduledExecutionPerformerStub = new ScheduledExecutionPerformerStub + val preparedDeploymentData = DeploymentData.withDeploymentId(UUID.randomUUID().toString) val periodicProcessService = new PeriodicProcessService( delegateDeploymentManager = delegateDeploymentManagerStub, - jarManager = jarManagerStub, - scheduledProcessesRepository = repository, + scheduledExecutionPerformer = scheduledExecutionPerformerStub, + periodicProcessesRepository = repository, periodicProcessListener = EmptyListener, additionalDeploymentDataProvider = DefaultAdditionalDeploymentDataProvider, deploymentRetryConfig = DeploymentRetryConfig(), @@ -76,15 +75,15 @@ class PeriodicDeploymentManagerTest processConfigEnricher = ProcessConfigEnricher.identity, clock = Clock.systemDefaultZone(), new ProcessingTypeActionServiceStub, - Map.empty + Map.empty, ) val periodicDeploymentManager = new PeriodicDeploymentManager( delegate = delegateDeploymentManagerStub, service = periodicProcessService, - repository = repository, + periodicProcessesRepository = repository, schedulePropertyExtractor = CronSchedulePropertyExtractor(), - toClose = () => () + toClose = () => (), ) def getAllowedActions( diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessDeploymentGen.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessDeploymentGen.scala similarity index 53% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessDeploymentGen.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessDeploymentGen.scala index cf813b7b011..dc7ab3014bd 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessDeploymentGen.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessDeploymentGen.scala @@ -1,14 +1,6 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithCanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.{ - PeriodicProcessDeployment, - PeriodicProcessDeploymentId, - PeriodicProcessDeploymentState, - PeriodicProcessDeploymentStatus, - ScheduleName -} +import pl.touk.nussknacker.ui.process.periodic.model.{PeriodicProcessDeployment, PeriodicProcessDeploymentId, PeriodicProcessDeploymentState, PeriodicProcessDeploymentStatus, ScheduleName} import java.time.LocalDateTime @@ -16,7 +8,7 @@ object PeriodicProcessDeploymentGen { val now: LocalDateTime = LocalDateTime.now() - def apply(): PeriodicProcessDeployment[WithCanonicalProcess] = { + def apply(): PeriodicProcessDeployment = { PeriodicProcessDeployment( id = PeriodicProcessDeploymentId(42), periodicProcess = PeriodicProcessGen(), diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessGen.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessGen.scala new file mode 100644 index 00000000000..7111db87542 --- /dev/null +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessGen.scala @@ -0,0 +1,39 @@ +package pl.touk.nussknacker.ui.process.periodic.flink + +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{DeploymentWithRuntimeParams, RuntimeParams} +import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} +import pl.touk.nussknacker.engine.build.ScenarioBuilder +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.ui.process.periodic.CronScheduleProperty +import pl.touk.nussknacker.ui.process.periodic.cron.CronSchedulePropertyExtractor.CronPropertyDefaultName +import pl.touk.nussknacker.ui.process.periodic.model.{PeriodicProcess, PeriodicProcessId} + +import java.time.LocalDateTime + +object PeriodicProcessGen { + + def apply(): PeriodicProcess = { + PeriodicProcess( + id = PeriodicProcessId(42), + deploymentData = DeploymentWithRuntimeParams( + processId = Some(ProcessId(1)), + processName = ProcessName(""), + versionId = VersionId.initialVersionId, + runtimeParams = RuntimeParams(Map("jarFileName" -> "jar-file-name.jar")) + ), + scheduleProperty = CronScheduleProperty("0 0 * * * ?"), + active = true, + createdAt = LocalDateTime.now(), + None + ) + } + + def buildCanonicalProcess(cronProperty: String = "0 0 * * * ?"): CanonicalProcess = { + ScenarioBuilder + .streaming("test") + .additionalFields(properties = Map(CronPropertyDefaultName -> cronProperty)) + .source("test", "test") + .emptySink("test", "test") + } + +} diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessServiceTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessServiceTest.scala similarity index 81% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessServiceTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessServiceTest.scala index a258d5c2ac4..c8c631a1e5c 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessServiceTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessServiceTest.scala @@ -1,37 +1,29 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import com.typesafe.config.{ConfigFactory, ConfigValueFactory} +import org.scalatest.OptionValues import org.scalatest.concurrent.ScalaFutures import org.scalatest.exceptions.TestFailedException -import org.scalatest.prop.TableDrivenPropertyChecks -import org.scalatest.OptionValues import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks import pl.touk.nussknacker.engine.api.ProcessVersion -import pl.touk.nussknacker.engine.api.deployment.{DataFreshnessPolicy, ProcessActionId, ProcessingTypeActionServiceStub} +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.ScheduledDeploymentDetails +import pl.touk.nussknacker.engine.api.deployment.scheduler.services._ +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.AdditionalDeploymentDataProvider +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.ProcessConfigEnricher.EnrichedProcessConfig import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus.ProblemStateStatus -import pl.touk.nussknacker.engine.api.process.ProcessName +import pl.touk.nussknacker.engine.api.deployment.{DataFreshnessPolicy, ProcessActionId, ProcessingTypeActionServiceStub} +import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} import pl.touk.nussknacker.engine.build.ScenarioBuilder -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.PeriodicProcessService.PeriodicProcessStatus -import pl.touk.nussknacker.engine.management.periodic.db.PeriodicProcessesRepository.createPeriodicProcessDeployment -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithCanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus -import pl.touk.nussknacker.engine.management.periodic.model.{PeriodicProcessDeployment, PeriodicProcessDeploymentStatus} -import pl.touk.nussknacker.engine.management.periodic.service.ProcessConfigEnricher.EnrichedProcessConfig -import pl.touk.nussknacker.engine.management.periodic.service.{ - AdditionalDeploymentDataProvider, - DeployedEvent, - FailedOnDeployEvent, - FailedOnRunEvent, - FinishedEvent, - PeriodicProcessEvent, - PeriodicProcessListener, - ProcessConfigEnricher, - ScheduledEvent -} import pl.touk.nussknacker.test.PatientScalaFutures +import pl.touk.nussknacker.ui.process.periodic.PeriodicProcessService.PeriodicProcessStatus +import pl.touk.nussknacker.ui.process.periodic._ +import pl.touk.nussknacker.ui.process.periodic.flink.db.InMemPeriodicProcessesRepository +import pl.touk.nussknacker.ui.process.periodic.flink.db.InMemPeriodicProcessesRepository.createPeriodicProcessDeployment +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus import java.time.temporal.ChronoField import java.time.{Clock, LocalDate, LocalDateTime} @@ -63,22 +55,31 @@ class PeriodicProcessServiceTest .source("start", "source") .emptySink("end", "KafkaSink") + private val processVersion = ProcessVersion( + versionId = VersionId(1), + processName = processName, + processId = ProcessId(1), + labels = List.empty, + user = "testUser", + modelVersion = None, + ) + class Fixture { - val repository = new db.InMemPeriodicProcessesRepository(processingType = "testProcessingType") - val delegateDeploymentManagerStub = new DeploymentManagerStub - val jarManagerStub = new JarManagerStub - val events = new ArrayBuffer[PeriodicProcessEvent]() - val additionalData = Map("testMap" -> "testValue") + val repository = new InMemPeriodicProcessesRepository(processingType = "testProcessingType") + val delegateDeploymentManagerStub = new DeploymentManagerStub + val scheduledExecutionPerformerStub = new ScheduledExecutionPerformerStub + val events = new ArrayBuffer[ScheduledProcessEvent]() + val additionalData = Map("testMap" -> "testValue") val actionService: ProcessingTypeActionServiceStub = new ProcessingTypeActionServiceStub val periodicProcessService = new PeriodicProcessService( delegateDeploymentManager = delegateDeploymentManagerStub, - jarManager = jarManagerStub, - scheduledProcessesRepository = repository, - new PeriodicProcessListener { + scheduledExecutionPerformer = scheduledExecutionPerformerStub, + periodicProcessesRepository = repository, + new ScheduledProcessListener { - override def onPeriodicProcessEvent: PartialFunction[PeriodicProcessEvent, Unit] = { case k => + override def onScheduledProcessEvent: PartialFunction[ScheduledProcessEvent, Unit] = { case k => events.append(k) } @@ -86,9 +87,9 @@ class PeriodicProcessServiceTest additionalDeploymentDataProvider = new AdditionalDeploymentDataProvider { override def prepareAdditionalData( - runDetails: PeriodicProcessDeployment[WithCanonicalProcess] + runDetails: ScheduledDeploymentDetails ): Map[String, String] = - additionalData + ("runId" -> runDetails.id.value.toString) + additionalData + ("runId" -> runDetails.id.toString) }, DeploymentRetryConfig(), @@ -103,7 +104,7 @@ class PeriodicProcessServiceTest EnrichedProcessConfig( initialScheduleData.inputConfigDuringExecution.withValue( "processName", - ConfigValueFactory.fromAnyRef(initialScheduleData.canonicalProcess.name.value) + ConfigValueFactory.fromAnyRef(processName.value) ) ) ) @@ -115,7 +116,7 @@ class PeriodicProcessServiceTest Future.successful( EnrichedProcessConfig( deployData.inputConfigDuringExecution - .withValue("runAt", ConfigValueFactory.fromAnyRef(deployData.deployment.runAt.toString)) + .withValue("runAt", ConfigValueFactory.fromAnyRef(deployData.deploymentDetails.runAt.toString)) ) ) } @@ -123,7 +124,7 @@ class PeriodicProcessServiceTest }, Clock.systemDefaultZone(), actionService, - Map.empty + Map.empty, ) } @@ -185,7 +186,10 @@ class PeriodicProcessServiceTest val finished :: scheduled :: Nil = f.repository.deploymentEntities.map(createPeriodicProcessDeployment(processEntity, _)).toList - f.events.toList shouldBe List(FinishedEvent(finished, None), ScheduledEvent(scheduled, firstSchedule = false)) + f.events.toList shouldBe List( + FinishedEvent(finished.toDetails, canonicalProcess, None), + ScheduledEvent(scheduled.toDetails, firstSchedule = false) + ) } // Flink job could not be available in Flink console if checked too quickly after submit. @@ -238,8 +242,12 @@ class PeriodicProcessServiceTest val finished :: scheduled :: Nil = f.repository.deploymentEntities.map(createPeriodicProcessDeployment(processEntity, _)).toList f.events.toList shouldBe List( - FinishedEvent(finished, f.delegateDeploymentManagerStub.jobStatus.get(processName).flatMap(_.headOption)), - ScheduledEvent(scheduled, firstSchedule = false) + FinishedEvent( + finished.toDetails, + canonicalProcess, + f.delegateDeploymentManagerStub.jobStatus.get(processName).flatMap(_.headOption) + ), + ScheduledEvent(scheduled.toDetails, firstSchedule = false) ) } @@ -280,9 +288,13 @@ class PeriodicProcessServiceTest f.repository.deploymentEntities.loneElement.status shouldBe PeriodicProcessDeploymentStatus.Finished // TODO: active should be false val event = - createPeriodicProcessDeployment(processEntity.copy(active = true), f.repository.deploymentEntities.loneElement) + createPeriodicProcessDeployment( + processEntity.copy(active = true), + f.repository.deploymentEntities.loneElement, + ) f.events.loneElement shouldBe FinishedEvent( - event, + event.toDetails, + canonicalProcess, f.delegateDeploymentManagerStub.jobStatus.get(processName).flatMap(_.headOption) ) } @@ -330,7 +342,7 @@ class PeriodicProcessServiceTest val f = new Fixture f.periodicProcessService - .schedule(CronScheduleProperty("0 0 * * * ?"), ProcessVersion.empty, canonicalProcess, randomProcessActionId) + .schedule(CronScheduleProperty("0 0 * * * ?"), processVersion, canonicalProcess, randomProcessActionId) .futureValue val processEntity = f.repository.processEntities.loneElement @@ -342,7 +354,10 @@ class PeriodicProcessServiceTest deploymentEntity.status shouldBe PeriodicProcessDeploymentStatus.Scheduled f.events.toList shouldBe List( - ScheduledEvent(createPeriodicProcessDeployment(processEntity, deploymentEntity), firstSchedule = true) + ScheduledEvent( + createPeriodicProcessDeployment(processEntity, deploymentEntity).toDetails, + firstSchedule = true + ) ) } @@ -358,10 +373,11 @@ class PeriodicProcessServiceTest processEntity.active shouldBe true f.repository.deploymentEntities.loneElement.status shouldBe PeriodicProcessDeploymentStatus.Failed - val expectedDetails = createPeriodicProcessDeployment(processEntity, f.repository.deploymentEntities.head) + val expectedDetails = + createPeriodicProcessDeployment(processEntity, f.repository.deploymentEntities.head) f.events.toList shouldBe List( FailedOnRunEvent( - expectedDetails, + expectedDetails.toDetails, f.delegateDeploymentManagerStub.jobStatus.get(processName).flatMap(_.headOption) ) ) @@ -372,7 +388,7 @@ class PeriodicProcessServiceTest f.repository.addActiveProcess(processName, PeriodicProcessDeploymentStatus.Scheduled) val toSchedule = createPeriodicProcessDeployment( f.repository.processEntities.loneElement, - f.repository.deploymentEntities.loneElement + f.repository.deploymentEntities.loneElement, ) f.periodicProcessService.deploy(toSchedule).futureValue @@ -380,21 +396,22 @@ class PeriodicProcessServiceTest val deploymentEntity = f.repository.deploymentEntities.loneElement deploymentEntity.status shouldBe PeriodicProcessDeploymentStatus.Deployed ConfigFactory - .parseString(f.jarManagerStub.lastDeploymentWithJarData.value.inputConfigDuringExecutionJson) + .parseString(f.scheduledExecutionPerformerStub.lastInputConfigDuringExecutionJson.value) .getString("runAt") shouldBe deploymentEntity.runAt.toString - val expectedDetails = createPeriodicProcessDeployment(f.repository.processEntities.loneElement, deploymentEntity) - f.events.toList shouldBe List(DeployedEvent(expectedDetails, None)) + val expectedDetails = + createPeriodicProcessDeployment(f.repository.processEntities.loneElement, deploymentEntity) + f.events.toList shouldBe List(DeployedEvent(expectedDetails.toDetails, None)) } test("deploy - should handle failed deployment") { val f = new Fixture f.repository.addActiveProcess(processName, PeriodicProcessDeploymentStatus.Scheduled) - f.jarManagerStub.deployWithJarFuture = Future.failed(new RuntimeException("Flink deploy error")) + f.scheduledExecutionPerformerStub.deployWithJarFuture = Future.failed(new RuntimeException("Flink deploy error")) val toSchedule = createPeriodicProcessDeployment( f.repository.processEntities.loneElement, - f.repository.deploymentEntities.loneElement + f.repository.deploymentEntities.loneElement, ) f.periodicProcessService.deploy(toSchedule).futureValue @@ -403,9 +420,9 @@ class PeriodicProcessServiceTest val expectedDetails = createPeriodicProcessDeployment( f.repository.processEntities.loneElement, - f.repository.deploymentEntities.loneElement + f.repository.deploymentEntities.loneElement, ) - f.events.toList shouldBe List(FailedOnDeployEvent(expectedDetails, None)) + f.events.toList shouldBe List(FailedOnDeployEvent(expectedDetails.toDetails, None)) } test("Schedule new scenario only if at least one date in the future") { @@ -413,7 +430,7 @@ class PeriodicProcessServiceTest def tryToSchedule(schedule: ScheduleProperty): Unit = f.periodicProcessService - .schedule(schedule, ProcessVersion.empty, canonicalProcess, randomProcessActionId) + .schedule(schedule, processVersion, canonicalProcess, randomProcessActionId) .futureValue tryToSchedule(cronInFuture) shouldBe (()) diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessStateDefinitionManagerTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessStateDefinitionManagerTest.scala similarity index 86% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessStateDefinitionManagerTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessStateDefinitionManagerTest.scala index 77e551054a2..49f51d166d1 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessStateDefinitionManagerTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/PeriodicProcessStateDefinitionManagerTest.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -6,10 +6,11 @@ import pl.touk.nussknacker.engine.api.deployment.ProcessStateDefinitionManager.P import pl.touk.nussknacker.engine.api.deployment.ScenarioActionName import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.process.VersionId -import pl.touk.nussknacker.engine.management.periodic.PeriodicProcessService.{DeploymentStatus, PeriodicProcessStatus} -import pl.touk.nussknacker.engine.management.periodic.PeriodicProcessStateDefinitionManager.statusTooltip -import pl.touk.nussknacker.engine.management.periodic.PeriodicStateStatus.ScheduledStatus -import pl.touk.nussknacker.engine.management.periodic.model._ +import pl.touk.nussknacker.ui.process.periodic.PeriodicProcessService.{PeriodicDeploymentStatus, PeriodicProcessStatus} +import pl.touk.nussknacker.ui.process.periodic.PeriodicProcessStateDefinitionManager.statusTooltip +import pl.touk.nussknacker.ui.process.periodic.PeriodicStateStatus +import pl.touk.nussknacker.ui.process.periodic.PeriodicStateStatus.ScheduledStatus +import pl.touk.nussknacker.ui.process.periodic.model._ import java.time.LocalDateTime import java.util.concurrent.atomic.AtomicLong @@ -29,7 +30,7 @@ class PeriodicProcessStateDefinitionManagerTest extends AnyFunSuite with Matcher private val nextScheduleId = new AtomicLong() test("display periodic deployment status for not named schedule") { - val deploymentStatus = DeploymentStatus( + val deploymentStatus = PeriodicDeploymentStatus( generateDeploymentId, notNamedScheduleId, fooCreatedAt, @@ -44,7 +45,7 @@ class PeriodicProcessStateDefinitionManagerTest extends AnyFunSuite with Matcher test("display sorted periodic deployment status for named schedules") { val firstScheduleId = generateScheduleId - val firstDeploymentStatus = DeploymentStatus( + val firstDeploymentStatus = PeriodicDeploymentStatus( generateDeploymentId, firstScheduleId, fooCreatedAt.minusMinutes(1), @@ -54,7 +55,7 @@ class PeriodicProcessStateDefinitionManagerTest extends AnyFunSuite with Matcher None ) val secScheduleId = generateScheduleId - val secDeploymentStatus = DeploymentStatus( + val secDeploymentStatus = PeriodicDeploymentStatus( generateDeploymentId, secScheduleId, fooCreatedAt, diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/RescheduleFinishedActorTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/RescheduleFinishedActorTest.scala similarity index 91% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/RescheduleFinishedActorTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/RescheduleFinishedActorTest.scala index 720364a78d4..aa05869e6ec 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/RescheduleFinishedActorTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/RescheduleFinishedActorTest.scala @@ -1,10 +1,11 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import akka.actor.ActorSystem import akka.testkit.{TestKit, TestKitBase, TestProbe} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import org.scalatest.BeforeAndAfterAll +import pl.touk.nussknacker.ui.process.periodic.RescheduleFinishedActor import scala.concurrent.Future import scala.concurrent.duration._ diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/ScheduledExecutionPerformerStub.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/ScheduledExecutionPerformerStub.scala new file mode 100644 index 00000000000..40a4d76ca41 --- /dev/null +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/ScheduledExecutionPerformerStub.scala @@ -0,0 +1,49 @@ +package pl.touk.nussknacker.ui.process.periodic.flink + +import com.typesafe.config.ConfigFactory +import pl.touk.nussknacker.engine.api.ProcessVersion +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{DeploymentWithRuntimeParams, RuntimeParams} +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.ScheduledExecutionPerformer +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.engine.deployment.{DeploymentData, ExternalDeploymentId} +import pl.touk.nussknacker.engine.modelconfig.InputConfigDuringExecution + +import scala.concurrent.Future + +class ScheduledExecutionPerformerStub extends ScheduledExecutionPerformer { + + var deployWithJarFuture: Future[Option[ExternalDeploymentId]] = Future.successful(None) + var lastDeploymentWithRuntimeParams: Option[DeploymentWithRuntimeParams] = None + var lastInputConfigDuringExecutionJson: Option[String] = None + + override def prepareDeploymentWithRuntimeParams( + processVersion: ProcessVersion, + ): Future[DeploymentWithRuntimeParams] = { + Future.successful( + DeploymentWithRuntimeParams( + processId = Some(processVersion.processId), + processName = processVersion.processName, + versionId = processVersion.versionId, + runtimeParams = RuntimeParams(Map("jarFileName" -> "")) + ) + ) + } + + override def provideInputConfigDuringExecutionJson(): Future[InputConfigDuringExecution] = + Future.successful(InputConfigDuringExecution(ConfigFactory.parseString(""))) + + override def deployWithRuntimeParams( + deploymentWithJarData: DeploymentWithRuntimeParams, + inputConfigDuringExecutionJson: String, + deploymentData: DeploymentData, + canonicalProcess: CanonicalProcess, + processVersion: ProcessVersion, + ): Future[Option[ExternalDeploymentId]] = { + lastDeploymentWithRuntimeParams = Some(deploymentWithJarData) + lastInputConfigDuringExecutionJson = Some(inputConfigDuringExecutionJson) + deployWithJarFuture + } + + override def cleanAfterDeployment(runtimeParams: RuntimeParams): Future[Unit] = Future.successful(()) + +} diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/JarManagerTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/ScheduledExecutionPerformerTest.scala similarity index 53% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/JarManagerTest.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/ScheduledExecutionPerformerTest.scala index b63433a8795..2dc0e14252d 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/JarManagerTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/ScheduledExecutionPerformerTest.scala @@ -1,28 +1,26 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import com.typesafe.config.ConfigFactory import org.scalatest.concurrent.ScalaFutures import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers +import pl.touk.nussknacker.engine.api.ProcessVersion +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{DeploymentWithRuntimeParams, RuntimeParams} +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.ScheduledExecutionPerformer import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId} -import pl.touk.nussknacker.engine.api.{MetaData, ProcessVersion, StreamMetaData} -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.FlinkModelJarProvider -import pl.touk.nussknacker.engine.management.periodic.flink.FlinkJarManager -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData +import pl.touk.nussknacker.engine.management.{FlinkModelJarProvider, FlinkScheduledExecutionPerformer} import pl.touk.nussknacker.engine.modelconfig.InputConfigDuringExecution import pl.touk.nussknacker.test.PatientScalaFutures import java.nio.file.{Files, Path, Paths} import scala.concurrent.Future -class JarManagerTest extends AnyFunSuite with Matchers with ScalaFutures with PatientScalaFutures { +class ScheduledExecutionPerformerTest extends AnyFunSuite with Matchers with ScalaFutures with PatientScalaFutures { private val processName = "test" private val processVersionId = 5 private val processVersion = ProcessVersion.empty.copy(processName = ProcessName(processName), versionId = VersionId(processVersionId)) - private val process = CanonicalProcess(MetaData("foo", StreamMetaData()), Nil) private val jarsDir = Files.createTempDirectory("jars-dir") private val modelJarFileContent = "abc".getBytes @@ -34,14 +32,14 @@ class JarManagerTest extends AnyFunSuite with Matchers with ScalaFutures with Pa private val currentModelUrls = List(currentModelJarFile.toURI.toURL) - private val jarManager = createJarManager(jarsDir = jarsDir) + private val scheduledExecutionPerformer = createScheduledExecutionPerformer(jarsDir = jarsDir) - private def createJarManager( + private def createScheduledExecutionPerformer( jarsDir: Path, modelJarProvider: FlinkModelJarProvider = new FlinkModelJarProvider(currentModelUrls) - ): JarManager = { + ): ScheduledExecutionPerformer = { - new FlinkJarManager( + new FlinkScheduledExecutionPerformer( flinkClient = new FlinkClientStub, jarsDir = jarsDir, inputConfigDuringExecution = InputConfigDuringExecution(ConfigFactory.empty()), @@ -50,9 +48,9 @@ class JarManagerTest extends AnyFunSuite with Matchers with ScalaFutures with Pa } test("prepareDeploymentWithJar - should copy to local dir") { - val result = jarManager.prepareDeploymentWithJar(processVersion, process) + val result = scheduledExecutionPerformer.prepareDeploymentWithRuntimeParams(processVersion) - val copiedJarFileName = result.futureValue.jarFileName + val copiedJarFileName = result.futureValue.runtimeParams.params("jarFileName") copiedJarFileName should fullyMatch regex s"^$processName-$processVersionId-\\d+\\.jar$$" val copiedJarFile = jarsDir.resolve(copiedJarFileName) Files.exists(copiedJarFile) shouldBe true @@ -60,33 +58,33 @@ class JarManagerTest extends AnyFunSuite with Matchers with ScalaFutures with Pa } test("prepareDeploymentWithJar - should handle disappearing model JAR") { - val modelJarProvider = new FlinkModelJarProvider(currentModelUrls) - val jarManager = createJarManager(jarsDir, modelJarProvider) + val modelJarProvider = new FlinkModelJarProvider(currentModelUrls) + val scheduledExecutionPerformer = createScheduledExecutionPerformer(jarsDir, modelJarProvider) - def verifyAndDeleteJar(result: Future[DeploymentWithJarData.WithCanonicalProcess]): Unit = { - val copiedJarFile = jarsDir.resolve(result.futureValue.jarFileName) + def verifyAndDeleteJar(result: Future[DeploymentWithRuntimeParams]): Unit = { + val copiedJarFile = jarsDir.resolve(result.futureValue.runtimeParams.params("jarFileName")) Files.exists(copiedJarFile) shouldBe true Files.readAllBytes(copiedJarFile) shouldBe modelJarFileContent Files.delete(copiedJarFile) } - verifyAndDeleteJar(jarManager.prepareDeploymentWithJar(processVersion, process)) + verifyAndDeleteJar(scheduledExecutionPerformer.prepareDeploymentWithRuntimeParams(processVersion)) modelJarProvider.getJobJar().delete() shouldBe true - verifyAndDeleteJar(jarManager.prepareDeploymentWithJar(processVersion, process)) + verifyAndDeleteJar(scheduledExecutionPerformer.prepareDeploymentWithRuntimeParams(processVersion)) } test("prepareDeploymentWithJar - should create jars dir if not exists") { - val tmpDir = System.getProperty("java.io.tmpdir") - val jarsDir = Paths.get(tmpDir, s"jars-dir-not-exists-${System.currentTimeMillis()}") - val jarManager = createJarManager(jarsDir = jarsDir) + val tmpDir = System.getProperty("java.io.tmpdir") + val jarsDir = Paths.get(tmpDir, s"jars-dir-not-exists-${System.currentTimeMillis()}") + val scheduledExecutionPerformer = createScheduledExecutionPerformer(jarsDir = jarsDir) Files.exists(jarsDir) shouldBe false - val result = jarManager.prepareDeploymentWithJar(processVersion, process) + val result = scheduledExecutionPerformer.prepareDeploymentWithRuntimeParams(processVersion) - val copiedJarFileName = result.futureValue.jarFileName + val copiedJarFileName = result.futureValue.runtimeParams.params("jarFileName") Files.exists(jarsDir) shouldBe true Files.exists(jarsDir.resolve(copiedJarFileName)) shouldBe true } @@ -96,13 +94,14 @@ class JarManagerTest extends AnyFunSuite with Matchers with ScalaFutures with Pa val jarPath = jarsDir.resolve(jarFileName) Files.copy(currentModelJarFile.toPath, jarPath) - jarManager.deleteJar(jarFileName).futureValue + scheduledExecutionPerformer.cleanAfterDeployment(RuntimeParams(Map("jarFileName" -> jarFileName))).futureValue Files.exists(jarPath) shouldBe false } test("deleteJar - should handle not existing file") { - val result = jarManager.deleteJar("unknown.jar").futureValue + val result = + scheduledExecutionPerformer.cleanAfterDeployment(RuntimeParams(Map("jarFileName" -> "unknown.jar"))).futureValue result shouldBe (()) } diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/UtilsSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/UtilsSpec.scala similarity index 94% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/UtilsSpec.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/UtilsSpec.scala index a6d53da9c16..ace8ffe0fc7 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/UtilsSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/UtilsSpec.scala @@ -1,4 +1,4 @@ -package pl.touk.nussknacker.engine.management.periodic +package pl.touk.nussknacker.ui.process.periodic.flink import akka.actor.{Actor, ActorSystem, Props} import akka.testkit.TestKit @@ -6,7 +6,8 @@ import com.typesafe.scalalogging.LazyLogging import org.scalatest.BeforeAndAfterAll import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpecLike -import pl.touk.nussknacker.engine.management.periodic.Utils.createActorWithRetry +import pl.touk.nussknacker.ui.process.periodic.Utils +import pl.touk.nussknacker.ui.process.periodic.Utils.createActorWithRetry import scala.concurrent.duration.Duration import scala.concurrent.{Await, Future} diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/db/InMemPeriodicProcessesRepository.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/db/InMemPeriodicProcessesRepository.scala similarity index 53% rename from engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/db/InMemPeriodicProcessesRepository.scala rename to designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/db/InMemPeriodicProcessesRepository.scala index 2ebb103de28..16a14532615 100644 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/db/InMemPeriodicProcessesRepository.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/periodic/flink/db/InMemPeriodicProcessesRepository.scala @@ -1,20 +1,18 @@ -package pl.touk.nussknacker.engine.management.periodic.db +package pl.touk.nussknacker.ui.process.periodic.flink.db -import cats.{Id, Monad} import io.circe.syntax.EncoderOps +import pl.touk.nussknacker.engine.api.ProcessVersion +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{DeploymentWithRuntimeParams, RuntimeParams} import pl.touk.nussknacker.engine.api.deployment.ProcessActionId -import pl.touk.nussknacker.engine.api.process.{ProcessName, VersionId} +import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} import pl.touk.nussknacker.engine.build.ScenarioBuilder -import pl.touk.nussknacker.engine.management.periodic._ -import pl.touk.nussknacker.engine.management.periodic.db.InMemPeriodicProcessesRepository.{ - DeploymentIdSequence, - ProcessIdSequence, - getLatestDeploymentQueryCount -} -import pl.touk.nussknacker.engine.management.periodic.db.PeriodicProcessesRepository.createPeriodicProcessDeployment -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithCanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus -import pl.touk.nussknacker.engine.management.periodic.model._ +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.ui.process.periodic._ +import pl.touk.nussknacker.ui.process.periodic.flink.db.InMemPeriodicProcessesRepository._ +import pl.touk.nussknacker.ui.process.periodic.flink.db.InMemPeriodicProcessesRepository.getLatestDeploymentQueryCount +import pl.touk.nussknacker.ui.process.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus +import pl.touk.nussknacker.ui.process.periodic.model._ +import pl.touk.nussknacker.ui.process.repository.PeriodicProcessesRepository import java.time.chrono.ChronoLocalDateTime import java.time.{LocalDateTime, ZoneId} @@ -24,25 +22,23 @@ import scala.collection.mutable.ListBuffer import scala.concurrent.Future import scala.util.Random -object InMemPeriodicProcessesRepository { - private val ProcessIdSequence = new AtomicLong(0) - private val DeploymentIdSequence = new AtomicLong(0) - - val getLatestDeploymentQueryCount = new AtomicLong(0) -} - class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicProcessesRepository { - var processEntities: mutable.ListBuffer[PeriodicProcessEntityWithJson] = ListBuffer.empty - var deploymentEntities: mutable.ListBuffer[PeriodicProcessDeploymentEntity] = ListBuffer.empty + override type Action[T] = Future[T] - private implicit val localDateOrdering: Ordering[LocalDateTime] = Ordering.by(identity[ChronoLocalDateTime[_]]) + override def run[T](action: Future[T]): Future[T] = action - override type Action[T] = Id[T] + var processEntities: mutable.ListBuffer[TestPeriodicProcessEntity] = ListBuffer.empty + var deploymentEntities: mutable.ListBuffer[TestPeriodicProcessDeploymentEntity] = ListBuffer.empty - override implicit def monad: Monad[Id] = cats.catsInstancesForId + private def canonicalProcess(processName: ProcessName) = { + ScenarioBuilder + .streaming(processName.value) + .source("start", "source") + .emptySink("end", "KafkaSink") + } - override def run[T](action: Id[T]): Future[T] = Future.successful(action) + private implicit val localDateOrdering: Ordering[LocalDateTime] = Ordering.by(identity[ChronoLocalDateTime[_]]) def addActiveProcess( processName: ProcessName, @@ -71,17 +67,14 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP processActionId: Option[ProcessActionId] = None ): PeriodicProcessId = { val id = PeriodicProcessId(ProcessIdSequence.incrementAndGet()) - val entity = PeriodicProcessEntityWithJson( + val entity = TestPeriodicProcessEntity( id = id, + processId = None, processName = processName, processVersionId = VersionId.initialVersionId, processingType = processingType, - processJson = ScenarioBuilder - .streaming(processName.value) - .source("start", "source") - .emptySink("end", "KafkaSink"), inputConfigDuringExecutionJson = "{}", - jarFileName = "", + runtimeParams = RuntimeParams(Map("jarFileName" -> "")), scheduleProperty = scheduleProperty.asJson.noSpaces, active = true, createdAt = LocalDateTime.now(), @@ -100,7 +93,7 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP deployedAt: Option[LocalDateTime] = None, ): PeriodicProcessDeploymentId = { val id = PeriodicProcessDeploymentId(DeploymentIdSequence.incrementAndGet()) - val entity = PeriodicProcessDeploymentEntity( + val entity = TestPeriodicProcessDeploymentEntity( id = id, periodicProcessId = periodicProcessId, createdAt = LocalDateTime.now(), @@ -119,46 +112,49 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP override def getSchedulesState( scenarioName: ProcessName, after: Option[LocalDateTime], - ): Action[SchedulesState] = { + ): Future[SchedulesState] = Future.successful { val filteredProcesses = processEntities.filter { pe => pe.processName == scenarioName && deploymentEntities.exists(d => d.periodicProcessId == pe.id) }.toSeq getLatestDeploymentsForPeriodicProcesses(filteredProcesses, deploymentsPerScheduleMaxCount = Int.MaxValue) } - override def markInactive(processId: PeriodicProcessId): Unit = + override def markInactive(processId: PeriodicProcessId): Future[Unit] = Future.successful { processEntities.zipWithIndex .find { case (process, _) => process.id == processId } .foreach { case (process, index) => processEntities.update(index, process.copy(active = false)) } + } override def create( - deploymentWithJarData: DeploymentWithJarData.WithCanonicalProcess, + deploymentWithRuntimeParams: DeploymentWithRuntimeParams, + inputConfigDuringExecutionJson: String, + canonicalProcess: CanonicalProcess, scheduleProperty: ScheduleProperty, processActionId: ProcessActionId, - ): PeriodicProcess[WithCanonicalProcess] = { + ): Future[PeriodicProcess] = Future.successful { val id = PeriodicProcessId(Random.nextLong()) - val periodicProcess = PeriodicProcessEntityWithJson( + val periodicProcess = TestPeriodicProcessEntity( id = id, - processName = deploymentWithJarData.processVersion.processName, - processVersionId = deploymentWithJarData.processVersion.versionId, + processId = deploymentWithRuntimeParams.processId, + processName = deploymentWithRuntimeParams.processName, + processVersionId = deploymentWithRuntimeParams.versionId, processingType = processingType, - processJson = deploymentWithJarData.process, - inputConfigDuringExecutionJson = deploymentWithJarData.inputConfigDuringExecutionJson, - jarFileName = deploymentWithJarData.jarFileName, + inputConfigDuringExecutionJson = inputConfigDuringExecutionJson, + runtimeParams = deploymentWithRuntimeParams.runtimeParams, scheduleProperty = scheduleProperty.asJson.noSpaces, active = true, createdAt = LocalDateTime.now(), processActionId = Some(processActionId) ) processEntities += periodicProcess - PeriodicProcessesRepository.createPeriodicProcessWithJson(periodicProcess) + createPeriodicProcessWithJson(periodicProcess) } override def findActiveSchedulesForProcessesHavingDeploymentWithMatchingStatus( - expectedDeploymentStatuses: Set[PeriodicProcessDeploymentStatus] - ): Action[SchedulesState] = { + expectedDeploymentStatuses: Set[PeriodicProcessDeploymentStatus], + ): Future[SchedulesState] = Future.successful { val filteredProcesses = processEntities.filter { pe => pe.processingType == processingType && deploymentEntities.exists(d => d.periodicProcessId == pe.id && expectedDeploymentStatuses.contains(d.status)) @@ -168,8 +164,8 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP override def getLatestDeploymentsForActiveSchedules( processName: ProcessName, - deploymentsPerScheduleMaxCount: Int - ): Action[SchedulesState] = { + deploymentsPerScheduleMaxCount: Int, + ): Future[SchedulesState] = Future.successful { getLatestDeploymentQueryCount.incrementAndGet() getLatestDeploymentsForPeriodicProcesses( processEntities(processName).filter(_.active), @@ -179,7 +175,7 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP override def getLatestDeploymentsForActiveSchedules( deploymentsPerScheduleMaxCount: Int - ): Action[Map[ProcessName, SchedulesState]] = { + ): Future[Map[ProcessName, SchedulesState]] = Future.successful { getLatestDeploymentQueryCount.incrementAndGet() allProcessEntities.map { case (processName, list) => processName -> getLatestDeploymentsForPeriodicProcesses( @@ -192,8 +188,8 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP override def getLatestDeploymentsForLatestInactiveSchedules( processName: ProcessName, inactiveProcessesMaxCount: Int, - deploymentsPerScheduleMaxCount: Int - ): Action[SchedulesState] = { + deploymentsPerScheduleMaxCount: Int, + ): Future[SchedulesState] = Future.successful { getLatestDeploymentQueryCount.incrementAndGet() val filteredProcesses = processEntities(processName).filterNot(_.active).sortBy(_.createdAt).takeRight(inactiveProcessesMaxCount) @@ -203,7 +199,7 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP override def getLatestDeploymentsForLatestInactiveSchedules( inactiveProcessesMaxCount: Int, deploymentsPerScheduleMaxCount: Int - ): Action[Map[ProcessName, SchedulesState]] = { + ): Future[Map[ProcessName, SchedulesState]] = Future.successful { getLatestDeploymentQueryCount.incrementAndGet() allProcessEntities.map { case (processName, list) => processName -> getLatestDeploymentsForPeriodicProcesses( @@ -214,9 +210,9 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP } private def getLatestDeploymentsForPeriodicProcesses( - processes: Seq[PeriodicProcessEntity], + processes: Seq[TestPeriodicProcessEntity], deploymentsPerScheduleMaxCount: Int - ): SchedulesState = + ): SchedulesState = { SchedulesState((for { process <- processes deploymentGroupedByScheduleName <- deploymentEntities @@ -227,49 +223,48 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP val ds = deployments .sortBy(d => -d.runAt.atZone(ZoneId.systemDefault()).toInstant.toEpochMilli) .take(deploymentsPerScheduleMaxCount) - .map(ScheduleDeploymentData(_)) + .map(scheduleDeploymentData(_)) .toList - scheduleId -> ScheduleData(PeriodicProcessesRepository.createPeriodicProcessWithoutJson(process), ds) + scheduleId -> ScheduleData(createPeriodicProcessWithoutJson(process), ds) } } yield deploymentGroupedByScheduleName).toMap) + } - override def findToBeDeployed: Seq[PeriodicProcessDeployment[WithCanonicalProcess]] = { + override def findToBeDeployed: Future[Seq[PeriodicProcessDeployment]] = { val scheduled = findActive(PeriodicProcessDeploymentStatus.Scheduled) readyToRun(scheduled) } - override def findToBeRetried: Action[Seq[PeriodicProcessDeployment[WithCanonicalProcess]]] = { + override def findToBeRetried: Future[Seq[PeriodicProcessDeployment]] = { val toBeRetried = findActive(PeriodicProcessDeploymentStatus.FailedOnDeploy).filter(_.retriesLeft > 0) readyToRun(toBeRetried) } - override def findProcessData(id: PeriodicProcessDeploymentId): PeriodicProcessDeployment[WithCanonicalProcess] = + override def findProcessData( + id: PeriodicProcessDeploymentId, + ): Future[PeriodicProcessDeployment] = Future.successful { (for { d <- deploymentEntities if d.id == id p <- processEntities if p.id == d.periodicProcessId } yield createPeriodicProcessDeployment(p, d)).head + } - override def findProcessData(processName: ProcessName): Seq[PeriodicProcess[WithCanonicalProcess]] = - processEntities(processName) - .filter(_.active) - .map(PeriodicProcessesRepository.createPeriodicProcessWithJson) - - private def allProcessEntities: Map[ProcessName, Seq[PeriodicProcessEntity]] = + private def processEntities(processName: ProcessName): Seq[TestPeriodicProcessEntity] = processEntities - .filter(process => process.processingType == processingType) + .filter(process => process.processName == processName && process.processingType == processingType) .toSeq - .groupBy(_.processName) - private def processEntities(processName: ProcessName): Seq[PeriodicProcessEntityWithJson] = + private def allProcessEntities: Map[ProcessName, Seq[TestPeriodicProcessEntity]] = processEntities - .filter(process => process.processName == processName && process.processingType == processingType) + .filter(process => process.processingType == processingType) .toSeq + .groupBy(_.processName) - override def markDeployed(id: PeriodicProcessDeploymentId): Unit = { + override def markDeployed(id: PeriodicProcessDeploymentId): Future[Unit] = Future.successful { update(id)(_.copy(status = PeriodicProcessDeploymentStatus.Deployed, deployedAt = Some(LocalDateTime.now()))) } - override def markFinished(id: PeriodicProcessDeploymentId): Unit = { + override def markFinished(id: PeriodicProcessDeploymentId): Future[Unit] = Future.successful { update(id)(_.copy(status = PeriodicProcessDeploymentStatus.Finished, completedAt = Some(LocalDateTime.now()))) } @@ -278,7 +273,7 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP status: PeriodicProcessDeploymentStatus, deployRetries: Int, retryAt: Option[LocalDateTime] - ): Action[Unit] = { + ): Future[Unit] = Future.successful { update(id)( _.copy( status = status, @@ -289,7 +284,7 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP ) } - override def markFailed(id: PeriodicProcessDeploymentId): Unit = { + override def markFailed(id: PeriodicProcessDeploymentId): Future[Unit] = Future.successful { update(id)( _.copy( status = PeriodicProcessDeploymentStatus.Failed, @@ -302,9 +297,9 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP id: PeriodicProcessId, scheduleName: ScheduleName, runAt: LocalDateTime, - deployMaxRetries: Int - ): PeriodicProcessDeployment[WithCanonicalProcess] = { - val deploymentEntity = PeriodicProcessDeploymentEntity( + deployMaxRetries: Int, + ): Future[PeriodicProcessDeployment] = Future.successful { + val deploymentEntity = TestPeriodicProcessDeploymentEntity( id = PeriodicProcessDeploymentId(Random.nextLong()), periodicProcessId = id, createdAt = LocalDateTime.now(), @@ -317,12 +312,13 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP status = PeriodicProcessDeploymentStatus.Scheduled ) deploymentEntities += deploymentEntity - createPeriodicProcessDeployment(processEntities.find(_.id == id).head, deploymentEntity) + val processEntity = processEntities.find(_.id == id).head + createPeriodicProcessDeployment(processEntity, deploymentEntity) } private def update( id: PeriodicProcessDeploymentId - )(action: PeriodicProcessDeploymentEntity => PeriodicProcessDeploymentEntity): Unit = { + )(action: TestPeriodicProcessDeploymentEntity => TestPeriodicProcessDeploymentEntity): Unit = { deploymentEntities.zipWithIndex .find { case (deployment, _) => deployment.id == id } .foreach { case (deployment, index) => @@ -330,26 +326,157 @@ class InMemPeriodicProcessesRepository(processingType: String) extends PeriodicP } } - private def findActive( - status: PeriodicProcessDeploymentStatus - ): Seq[PeriodicProcessDeployment[WithCanonicalProcess]] = + private def findActive(status: PeriodicProcessDeploymentStatus): Seq[PeriodicProcessDeployment] = findActive( Seq(status) ) private def findActive( statusList: Seq[PeriodicProcessDeploymentStatus] - ): Seq[PeriodicProcessDeployment[WithCanonicalProcess]] = + ): Seq[PeriodicProcessDeployment] = (for { p <- processEntities if p.active && p.processingType == processingType d <- deploymentEntities if d.periodicProcessId == p.id && statusList.contains(d.status) } yield createPeriodicProcessDeployment(p, d)).toSeq private def readyToRun( - deployments: Seq[PeriodicProcessDeployment[WithCanonicalProcess]] - ): Seq[PeriodicProcessDeployment[WithCanonicalProcess]] = { + deployments: Seq[PeriodicProcessDeployment] + ): Future[Seq[PeriodicProcessDeployment]] = { val now = LocalDateTime.now() - deployments.filter(d => d.runAt.isBefore(now) || d.runAt.isEqual(now)) + Future.successful(deployments.filter(d => d.runAt.isBefore(now) || d.runAt.isEqual(now))) + } + + override def fetchCanonicalProcessWithVersion( + processName: ProcessName, + versionId: VersionId + ): Future[Option[(CanonicalProcess, ProcessVersion)]] = Future.successful { + Some(canonicalProcess(processName), ProcessVersion.empty) + } + + override def fetchInputConfigDuringExecutionJson( + processName: ProcessName, + versionId: VersionId + ): Future[Option[String]] = + Future.successful(Some("{}")) + +} + +object InMemPeriodicProcessesRepository { + + val getLatestDeploymentQueryCount = new AtomicLong(0) + + private val ProcessIdSequence = new AtomicLong(0) + private val DeploymentIdSequence = new AtomicLong(0) + + final case class TestPeriodicProcessEntity( + id: PeriodicProcessId, + processId: Option[ProcessId], + processName: ProcessName, + processVersionId: VersionId, + processingType: String, + inputConfigDuringExecutionJson: String, + runtimeParams: RuntimeParams, + scheduleProperty: String, + active: Boolean, + createdAt: LocalDateTime, + processActionId: Option[ProcessActionId] + ) + + case class TestPeriodicProcessDeploymentEntity( + id: PeriodicProcessDeploymentId, + periodicProcessId: PeriodicProcessId, + createdAt: LocalDateTime, + runAt: LocalDateTime, + scheduleName: Option[String], + deployedAt: Option[LocalDateTime], + completedAt: Option[LocalDateTime], + retriesLeft: Int, + nextRetryAt: Option[LocalDateTime], + status: PeriodicProcessDeploymentStatus + ) + + def createPeriodicProcessDeployment( + processEntity: TestPeriodicProcessEntity, + processDeploymentEntity: TestPeriodicProcessDeploymentEntity + ): PeriodicProcessDeployment = { + val process = createPeriodicProcessWithJson(processEntity) + PeriodicProcessDeployment( + processDeploymentEntity.id, + process, + processDeploymentEntity.createdAt, + processDeploymentEntity.runAt, + ScheduleName(processDeploymentEntity.scheduleName), + processDeploymentEntity.retriesLeft, + processDeploymentEntity.nextRetryAt, + createPeriodicDeploymentState(processDeploymentEntity) + ) + } + + def createPeriodicDeploymentState( + processDeploymentEntity: TestPeriodicProcessDeploymentEntity + ): PeriodicProcessDeploymentState = { + PeriodicProcessDeploymentState( + processDeploymentEntity.deployedAt, + processDeploymentEntity.completedAt, + processDeploymentEntity.status + ) + } + + def createPeriodicProcessWithJson( + processEntity: TestPeriodicProcessEntity + ): PeriodicProcess = { + val scheduleProperty = prepareScheduleProperty(processEntity) + PeriodicProcess( + processEntity.id, + DeploymentWithRuntimeParams( + processId = processEntity.processId, + processName = processEntity.processName, + versionId = processEntity.processVersionId, + runtimeParams = processEntity.runtimeParams, + ), + scheduleProperty, + processEntity.active, + processEntity.createdAt, + processEntity.processActionId + ) + } + + def createPeriodicProcessWithoutJson( + processEntity: TestPeriodicProcessEntity + ): PeriodicProcess = { + val scheduleProperty = prepareScheduleProperty(processEntity) + PeriodicProcess( + processEntity.id, + DeploymentWithRuntimeParams( + processId = processEntity.processId, + processName = processEntity.processName, + versionId = processEntity.processVersionId, + runtimeParams = processEntity.runtimeParams, + ), + scheduleProperty, + processEntity.active, + processEntity.createdAt, + processEntity.processActionId + ) + } + + private def prepareScheduleProperty(processEntity: TestPeriodicProcessEntity) = { + val scheduleProperty = io.circe.parser + .decode[ScheduleProperty](processEntity.scheduleProperty) + .fold(e => throw new IllegalArgumentException(e), identity) + scheduleProperty + } + + private def scheduleDeploymentData(deployment: TestPeriodicProcessDeploymentEntity): ScheduleDeploymentData = { + ScheduleDeploymentData( + deployment.id, + deployment.createdAt, + deployment.runAt, + deployment.deployedAt, + deployment.retriesLeft, + deployment.nextRetryAt, + createPeriodicDeploymentState(deployment) + ) } } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeDataProviderSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeDataProviderSpec.scala index 64aa228791e..e76cb1f6f9a 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeDataProviderSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ProcessingTypeDataProviderSpec.scala @@ -10,6 +10,7 @@ import pl.touk.nussknacker.engine.testing.{DeploymentManagerProviderStub, LocalM import pl.touk.nussknacker.security.Permission import pl.touk.nussknacker.test.utils.domain.TestFactory import pl.touk.nussknacker.ui.UnauthorizedError +import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData.SchedulingForProcessingType import pl.touk.nussknacker.ui.process.processingtype.loader.LocalProcessingTypeDataLoader import pl.touk.nussknacker.ui.process.processingtype.provider.ProcessingTypeDataProvider import pl.touk.nussknacker.ui.security.api.RealLoggedUser @@ -58,7 +59,8 @@ class ProcessingTypeDataProviderSpec extends AnyFunSuite with Matchers { .loadProcessingTypeData( _ => modelDependencies, _ => TestFactory.deploymentManagerDependencies, - ModelClassLoaderProvider(allProcessingTypes.map(_ -> ModelClassLoaderDependencies(List.empty, None)).toMap) + ModelClassLoaderProvider(allProcessingTypes.map(_ -> ModelClassLoaderDependencies(List.empty, None)).toMap), + dbRef = None, ) .unsafeRunSync() } diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ScenarioParametersServiceTest.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ScenarioParametersServiceTest.scala index 792c9141fa1..4b5deae86c6 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ScenarioParametersServiceTest.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/process/processingtype/ScenarioParametersServiceTest.scala @@ -20,6 +20,7 @@ import pl.touk.nussknacker.security.Permission import pl.touk.nussknacker.test.ValidatedValuesDetailedMessage import pl.touk.nussknacker.test.utils.domain.TestFactory import pl.touk.nussknacker.ui.config.DesignerConfig +import pl.touk.nussknacker.ui.process.processingtype.ProcessingTypeData.SchedulingForProcessingType import pl.touk.nussknacker.ui.process.processingtype.loader.ProcessingTypesConfigBasedProcessingTypeDataLoader import pl.touk.nussknacker.ui.security.api.{LoggedUser, RealLoggedUser} @@ -301,7 +302,8 @@ class ScenarioParametersServiceTest designerConfig.processingTypeConfigs.configByProcessingType.mapValuesNow(conf => ModelClassLoaderDependencies(conf.classPath, None) ) - ) + ), + dbRef = None, ) .unsafeRunSync() val parametersService = processingTypeData.getCombined().parametersService diff --git a/docs/Changelog.md b/docs/Changelog.md index ef7c6979fc8..370f3e7431d 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -55,6 +55,9 @@ * [#7446](https://github.com/TouK/nussknacker/pull/7446) Small changes regarding node errors in fragments used in scenarios: * Fragment error node tips in scenarios are now clickable and open problematic node edit window in a new tab. * Fragment nodes are now highlighted when they contain nodes with errors. +* [#7364](https://github.com/TouK/nussknacker/pull/7364) PeriodicDeploymentManger is no longer a separate DM, but instead is an optional functionality and decorator for all DMs + * in order to use it, DM must implement interface `schedulingSupported`, that handles deployments on a specific engine + * implementation provided for Flink DM ## 1.18 diff --git a/docs/MigrationGuide.md b/docs/MigrationGuide.md index fd2975f560e..43052d7b8f0 100644 --- a/docs/MigrationGuide.md +++ b/docs/MigrationGuide.md @@ -40,9 +40,43 @@ To see the biggest differences please consult the [changelog](Changelog.md). * [#7379](https://github.com/TouK/nussknacker/pull/7379) Removed CustomAction mechanism. If there were any custom actions defined in some custom DeploymentManager implementation, they should be modified to use the predefined set of actions or otherwise replaced by custom links and handled outside Nussknacker. +* [#7364](https://github.com/TouK/nussknacker/pull/7364) + * the PeriodicDeploymentManager is no longer a separate DM type + * in `scenarioTypes` config section, the `deploymentConfig` of a periodic scenario type (only Flink was supported so far) may have looked like that: + ```hocon + deploymentConfig: { + type: "flinkPeriodic" + restUrl: "http://jobmanager:8081" + shouldVerifyBeforeDeploy: true + deploymentManager { + db: { }, + processingType: streaming, + jarsDir: ./storage/jars + } + } + ``` + * changes: + * the `type: "flinkPeriodic"` is no longer supported, instead `type: "flinkStreaming"` with additional setting `supportsPeriodicExecution: true` should be used + * the db config is now optional - the periodic DM may still use its custom datasource defined here in `legacyDb` section + * when custom `db` section not defined here, then main Nussknacker db will be used + * config after changes may look like that: + ```hocon + deploymentConfig: { + type: "flinkStreaming" + scheduling { + enabled: true + processingType: streaming, + jarsDir: ./storage/jars + legacyDb: { }, + } + restUrl: "http://jobmanager:8081" + shouldVerifyBeforeDeploy: true + } + ``` ### Code API changes * [#7368](https://github.com/TouK/nussknacker/pull/7368) Renamed `PeriodicSourceFactory` to `SampleGeneratorSourceFactory` +* [#7364](https://github.com/TouK/nussknacker/pull/7364) The DeploymentManager must implement `def schedulingSupport: SchedulingSupport`. If support not added, then `NoSchedulingSupport` should be used. ## In version 1.18.0 diff --git a/engine/development/deploymentManager/src/main/scala/pl/touk/nussknacker/development/manager/DevelopmentDeploymentManagerProvider.scala b/engine/development/deploymentManager/src/main/scala/pl/touk/nussknacker/development/manager/DevelopmentDeploymentManagerProvider.scala index 7a247f11d78..8d9ac57b817 100644 --- a/engine/development/deploymentManager/src/main/scala/pl/touk/nussknacker/development/manager/DevelopmentDeploymentManagerProvider.scala +++ b/engine/development/deploymentManager/src/main/scala/pl/touk/nussknacker/development/manager/DevelopmentDeploymentManagerProvider.scala @@ -190,6 +190,8 @@ class DevelopmentDeploymentManager(actorSystem: ActorSystem, modelData: BaseMode override def deploymentSynchronisationSupport: DeploymentSynchronisationSupport = NoDeploymentSynchronisationSupport override def stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport = NoStateQueryForAllScenariosSupport + + override def schedulingSupport: SchedulingSupport = NoSchedulingSupport } class DevelopmentDeploymentManagerProvider extends DeploymentManagerProvider { diff --git a/engine/development/deploymentManager/src/main/scala/pl/touk/nussknacker/development/manager/MockableDeploymentManagerProvider.scala b/engine/development/deploymentManager/src/main/scala/pl/touk/nussknacker/development/manager/MockableDeploymentManagerProvider.scala index 7627c005fb7..8ae2fa10945 100644 --- a/engine/development/deploymentManager/src/main/scala/pl/touk/nussknacker/development/manager/MockableDeploymentManagerProvider.scala +++ b/engine/development/deploymentManager/src/main/scala/pl/touk/nussknacker/development/manager/MockableDeploymentManagerProvider.scala @@ -117,6 +117,8 @@ object MockableDeploymentManagerProvider { override def stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport = NoStateQueryForAllScenariosSupport + override def schedulingSupport: SchedulingSupport = NoSchedulingSupport + override def managerSpecificScenarioActivities( processIdWithName: ProcessIdWithName, after: Option[Instant], diff --git a/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/AggregatesSpec.scala b/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/AggregatesSpec.scala index c4243070a9d..d875ce51b21 100644 --- a/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/AggregatesSpec.scala +++ b/engine/flink/components/base-tests/src/test/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/AggregatesSpec.scala @@ -116,7 +116,10 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat test("should calculate correct results for average aggregator on BigInt") { val agg = AverageAggregator - addElementsAndComputeResult(List(new BigInteger("7"), new BigInteger("8")), agg) shouldEqual new java.math.BigDecimal("7.5") + addElementsAndComputeResult( + List(new BigInteger("7"), new BigInteger("8")), + agg + ) shouldEqual new java.math.BigDecimal("7.5") } test("should calculate correct results for average aggregator on float") { @@ -133,20 +136,22 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat } test("some aggregators should produce null on single null input") { - forAll (Table( - "aggregator", - AverageAggregator, - SampleStandardDeviationAggregator, - PopulationStandardDeviationAggregator, - SampleVarianceAggregator, - PopulationVarianceAggregator, - MaxAggregator, - MinAggregator, - FirstAggregator, - LastAggregator, - SumAggregator, - MedianAggregator - )) { agg => + forAll( + Table( + "aggregator", + AverageAggregator, + SampleStandardDeviationAggregator, + PopulationStandardDeviationAggregator, + SampleVarianceAggregator, + PopulationVarianceAggregator, + MaxAggregator, + MinAggregator, + FirstAggregator, + LastAggregator, + SumAggregator, + MedianAggregator + ) + ) { agg => addElementsAndComputeResult(List(null), agg) shouldEqual null } } @@ -154,10 +159,10 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat test("should calculate correct results for standard deviation and variance on doubles") { val table = Table( ("aggregator", "value"), - ( SampleStandardDeviationAggregator, Math.sqrt(2.5) ), - ( PopulationStandardDeviationAggregator, Math.sqrt(2) ), - ( SampleVarianceAggregator, 2.5 ), - ( PopulationVarianceAggregator, 2.0 ) + (SampleStandardDeviationAggregator, Math.sqrt(2.5)), + (PopulationStandardDeviationAggregator, Math.sqrt(2)), + (SampleVarianceAggregator, 2.5), + (PopulationVarianceAggregator, 2.0) ) forAll(table) { (agg, expectedResult) => @@ -182,7 +187,10 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat test("should calculate correct results for median aggregator on BigInt") { val agg = MedianAggregator - addElementsAndComputeResult(List(new BigInteger("7"), new BigInteger("8")), agg) shouldEqual new java.math.BigDecimal("7.5") + addElementsAndComputeResult( + List(new BigInteger("7"), new BigInteger("8")), + agg + ) shouldEqual new java.math.BigDecimal("7.5") } test("should calculate correct results for median aggregator on floats") { @@ -225,10 +233,10 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat test("should calculate correct results for standard deviation and variance on integers") { val table = Table( ("aggregator", "value"), - ( SampleStandardDeviationAggregator, Math.sqrt(2.5) ), - ( PopulationStandardDeviationAggregator, Math.sqrt(2) ), - ( SampleVarianceAggregator, 2.5 ), - ( PopulationVarianceAggregator, 2.0 ) + (SampleStandardDeviationAggregator, Math.sqrt(2.5)), + (PopulationStandardDeviationAggregator, Math.sqrt(2)), + (SampleVarianceAggregator, 2.5), + (PopulationVarianceAggregator, 2.0) ) forAll(table) { (agg, expectedResult) => @@ -240,10 +248,10 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat test("should calculate correct results for standard deviation and variance on BigInt") { val table = Table( ("aggregator", "value"), - ( SampleStandardDeviationAggregator, BigDecimal(Math.sqrt(2.5)) ), - ( PopulationStandardDeviationAggregator, BigDecimal(Math.sqrt(2)) ), - ( SampleVarianceAggregator, BigDecimal(2.5) ), - ( PopulationVarianceAggregator, BigDecimal(2.0) ) + (SampleStandardDeviationAggregator, BigDecimal(Math.sqrt(2.5))), + (PopulationStandardDeviationAggregator, BigDecimal(Math.sqrt(2))), + (SampleVarianceAggregator, BigDecimal(2.5)), + (PopulationVarianceAggregator, BigDecimal(2.0)) ) forAll(table) { (agg, expectedResult) => @@ -258,10 +266,10 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat test("should calculate correct results for standard deviation and variance on float") { val table = Table( ("aggregator", "value"), - ( SampleStandardDeviationAggregator, Math.sqrt(2.5) ), - ( PopulationStandardDeviationAggregator, Math.sqrt(2) ), - ( SampleVarianceAggregator, 2.5 ), - ( PopulationVarianceAggregator, 2.0 ) + (SampleStandardDeviationAggregator, Math.sqrt(2.5)), + (PopulationStandardDeviationAggregator, Math.sqrt(2)), + (SampleVarianceAggregator, 2.5), + (PopulationVarianceAggregator, 2.0) ) forAll(table) { (agg, expectedResult) => @@ -273,10 +281,10 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat test("should calculate correct results for standard deviation and variance on BigDecimals") { val table = Table( ("aggregator", "value"), - ( SampleStandardDeviationAggregator, BigDecimal(Math.sqrt(2.5)) ), - ( PopulationStandardDeviationAggregator, BigDecimal(Math.sqrt(2)) ), - ( SampleVarianceAggregator, BigDecimal(2.5) ), - ( PopulationVarianceAggregator, BigDecimal(2.0) ) + (SampleStandardDeviationAggregator, BigDecimal(Math.sqrt(2.5))), + (PopulationStandardDeviationAggregator, BigDecimal(Math.sqrt(2))), + (SampleVarianceAggregator, BigDecimal(2.5)), + (PopulationVarianceAggregator, BigDecimal(2.0)) ) forAll(table) { (agg, expectedResult) => @@ -297,15 +305,15 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat test("some aggregators should ignore nulls ") { val table = Table( ("aggregator", "value"), - ( SampleStandardDeviationAggregator, Math.sqrt(2.5) ), - ( PopulationStandardDeviationAggregator, Math.sqrt(2) ), - ( SampleVarianceAggregator, 2.5 ), - ( PopulationVarianceAggregator, 2.0 ), - ( SumAggregator, 15.0), - ( MaxAggregator, 5.0), - ( MinAggregator, 1.0), - ( AverageAggregator, 3.0), - ( MedianAggregator, 3.0) + (SampleStandardDeviationAggregator, Math.sqrt(2.5)), + (PopulationStandardDeviationAggregator, Math.sqrt(2)), + (SampleVarianceAggregator, 2.5), + (PopulationVarianceAggregator, 2.0), + (SumAggregator, 15.0), + (MaxAggregator, 5.0), + (MinAggregator, 1.0), + (AverageAggregator, 3.0), + (MedianAggregator, 3.0) ) forAll(table) { (agg, expectedResult) => @@ -315,19 +323,21 @@ class AggregatesSpec extends AnyFunSuite with TableDrivenPropertyChecks with Mat } test("some aggregators should produce null on empty set") { - forAll (Table( - "aggregator", - AverageAggregator, - SampleStandardDeviationAggregator, - PopulationStandardDeviationAggregator, - SampleVarianceAggregator, - PopulationVarianceAggregator, - MaxAggregator, - MinAggregator, - FirstAggregator, - LastAggregator, - SumAggregator - )) { agg => + forAll( + Table( + "aggregator", + AverageAggregator, + SampleStandardDeviationAggregator, + PopulationStandardDeviationAggregator, + SampleVarianceAggregator, + PopulationVarianceAggregator, + MaxAggregator, + MinAggregator, + FirstAggregator, + LastAggregator, + SumAggregator + ) + ) { agg => val result = addElementsAndComputeResult(List(), agg) result shouldBe null } diff --git a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/aggregates.scala b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/aggregates.scala index 8f1ebfadcd6..7fe0b0122f6 100644 --- a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/aggregates.scala +++ b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/aggregates.scala @@ -5,9 +5,7 @@ import cats.data.{NonEmptyList, Validated} import cats.instances.list._ import org.apache.flink.api.common.typeinfo.TypeInfo import pl.touk.nussknacker.engine.api.typed.supertype.NumberTypesPromotionStrategy -import pl.touk.nussknacker.engine.api.typed.supertype.NumberTypesPromotionStrategy.{ - ForLargeFloatingNumbersOperation, -} +import pl.touk.nussknacker.engine.api.typed.supertype.NumberTypesPromotionStrategy.ForLargeFloatingNumbersOperation import pl.touk.nussknacker.engine.api.typed.typing._ import pl.touk.nussknacker.engine.api.typed.{NumberTypeUtils, typing} import pl.touk.nussknacker.engine.flink.api.typeinfo.caseclass.CaseClassTypeInfoFactory @@ -17,7 +15,6 @@ import pl.touk.nussknacker.engine.util.MathUtils import pl.touk.nussknacker.engine.util.validated.ValidatedSyntax._ import java.util -import scala.collection.mutable.ListBuffer import scala.jdk.CollectionConverters._ /* @@ -81,7 +78,8 @@ object aggregates { override def zero: Aggregate = new java.util.ArrayList[Number]() - override def addElement(el: Element, agg: Aggregate): Aggregate = if (el == null) agg else { + override def addElement(el: Element, agg: Aggregate): Aggregate = if (el == null) agg + else { agg.add(el) agg } @@ -95,7 +93,8 @@ object aggregates { result } - override def result(finalAggregate: Aggregate): AnyRef = MedianHelper.calculateMedian(finalAggregate.asScala.toList).orNull + override def result(finalAggregate: Aggregate): AnyRef = + MedianHelper.calculateMedian(finalAggregate.asScala.toList).orNull override def computeStoredType(input: TypingResult): Validated[String, TypingResult] = Valid( Typed.genericTypeClass[java.util.ArrayList[_]](List(input)) diff --git a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/median/MedianHelper.scala b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/median/MedianHelper.scala index a4685a3b9b5..379d07aead4 100644 --- a/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/median/MedianHelper.scala +++ b/engine/flink/components/base-unbounded/src/main/scala/pl/touk/nussknacker/engine/flink/util/transformer/aggregate/median/MedianHelper.scala @@ -1,8 +1,6 @@ package pl.touk.nussknacker.engine.flink.util.transformer.aggregate.median -import pl.touk.nussknacker.engine.api.typed.supertype.NumberTypesPromotionStrategy.{ - ForLargeFloatingNumbersOperation, -} +import pl.touk.nussknacker.engine.api.typed.supertype.NumberTypesPromotionStrategy.ForLargeFloatingNumbersOperation import pl.touk.nussknacker.engine.util.MathUtils import scala.annotation.tailrec @@ -15,7 +13,9 @@ object MedianHelper { if (numbers.isEmpty) { None } else if (numbers.size % 2 == 1) { - Some(MathUtils.convertToPromotedType(quickSelect(numbers, (numbers.size - 1) / 2))(ForLargeFloatingNumbersOperation)) + Some( + MathUtils.convertToPromotedType(quickSelect(numbers, (numbers.size - 1) / 2))(ForLargeFloatingNumbersOperation) + ) } else { // it is possible to fetch both numbers with single recursion, but it would complicate code val firstNumber = quickSelect(numbers, numbers.size / 2 - 1) diff --git a/engine/flink/management/periodic/README.md b/engine/flink/management/periodic/README.md deleted file mode 100644 index caa2148a016..00000000000 --- a/engine/flink/management/periodic/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Periodic scenarios deployment manager - -An experimental engine running scenarios periodicly according to a schedule such as a cron expression. - -When the deploy button is clicked in NK GUI, then the scenario is scheduled to be run in the future. When a scenario -should be run is described by a schedule, e.g. a cron expression set in scenario properties. During scenario scheduling, -deployment manager only prepares data needed to deploy scenario on a target engine (e.g. on Flink cluster). -scenario is deployed according to the schedule on the target engine. Periodic engine watches its completion. Afterwards -scenario is scheduled to be run again according to the schedule. - -## Usage - -- Implement `DeploymentManagerProvider` using `PeriodicDeploymentManagerProvider`. Following components need to provided: - - Underlying engine, currently only Flink is supported. - - Optional `SchedulePropertyExtractorFactory` to determine how to construct an instance of a periodic property. By default - a cron expression set in scenario properties is used to describe when a scenario should be run. - - Optional `ProcessConfigEnricherFactory` if you would like to extend scenario configuration, by default nothing is done. - - Optional `PeriodicProcessListenerFactory` to take some actions on scenario lifecycle. - - Optional `AdditionalDeploymentDataProvider` to inject additional deployment parameters. -- Add service provider with your `DeploymentManagerProvider` implementation. - -## Configuration - -Use `deploymentManager` with the following properties: - -- `db` - Nussknacker db configuration. -- `processingType` - processing type of scenarios to be managed by this instance of the periodic engine. -- `rescheduleCheckInterval` - frequency of checking finished scenarios to be rescheduled. Optional. -- `deployInterval` - frequency of checking scenarios to be deployed on Flink cluster. Optional. -- `deploymentRetry` - failed deployments configuration. By default retrying is disabled. - - `deployMaxRetries` - maximum amount of retries for failed deployment. - - `deployRetryPenalize` - an amount of time by which the next retry should be delayed. -- `jarsDir` - directory for jars storage. -- `maxFetchedPeriodicScenarioActivities` - optional, maximum number of latest ScenarioActivities that will be fetched, by default 200 diff --git a/engine/flink/management/periodic/src/main/resources/META-INF/services/pl.touk.nussknacker.engine.DeploymentManagerProvider b/engine/flink/management/periodic/src/main/resources/META-INF/services/pl.touk.nussknacker.engine.DeploymentManagerProvider deleted file mode 100644 index e3b1553f5e4..00000000000 --- a/engine/flink/management/periodic/src/main/resources/META-INF/services/pl.touk.nussknacker.engine.DeploymentManagerProvider +++ /dev/null @@ -1 +0,0 @@ -pl.touk.nussknacker.engine.management.periodic.FlinkPeriodicDeploymentManagerProvider diff --git a/engine/flink/management/periodic/src/main/resources/META-INF/services/pl.touk.nussknacker.engine.api.definition.CustomParameterValidator b/engine/flink/management/periodic/src/main/resources/META-INF/services/pl.touk.nussknacker.engine.api.definition.CustomParameterValidator deleted file mode 100644 index 202f7c5d586..00000000000 --- a/engine/flink/management/periodic/src/main/resources/META-INF/services/pl.touk.nussknacker.engine.api.definition.CustomParameterValidator +++ /dev/null @@ -1 +0,0 @@ -pl.touk.nussknacker.engine.management.periodic.cron.CronParameterValidator \ No newline at end of file diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/FlinkPeriodicDeploymentManagerProvider.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/FlinkPeriodicDeploymentManagerProvider.scala deleted file mode 100644 index 9ce983104cc..00000000000 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/FlinkPeriodicDeploymentManagerProvider.scala +++ /dev/null @@ -1,79 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic - -import cats.data.ValidatedNel -import com.typesafe.config.Config -import com.typesafe.scalalogging.LazyLogging -import pl.touk.nussknacker.engine.api.component.ScenarioPropertyConfig -import pl.touk.nussknacker.engine.api.definition.{MandatoryParameterValidator, StringParameterEditor} -import pl.touk.nussknacker.engine.api.deployment.DeploymentManager -import pl.touk.nussknacker.engine.deployment.EngineSetupName -import pl.touk.nussknacker.engine.management.periodic.cron.CronParameterValidator -import pl.touk.nussknacker.engine.management.{FlinkConfig, FlinkStreamingDeploymentManagerProvider} -import pl.touk.nussknacker.engine.management.periodic.service._ -import pl.touk.nussknacker.engine.util.config.ConfigEnrichments.RichConfig -import pl.touk.nussknacker.engine.{ - BaseModelData, - DeploymentManagerDependencies, - DeploymentManagerProvider, - MetaDataInitializer -} - -import scala.concurrent.duration.FiniteDuration - -class FlinkPeriodicDeploymentManagerProvider extends DeploymentManagerProvider with LazyLogging { - - private val delegate = new FlinkStreamingDeploymentManagerProvider() - - private val cronConfig = CronSchedulePropertyExtractor.CronPropertyDefaultName -> ScenarioPropertyConfig( - defaultValue = None, - editor = Some(StringParameterEditor), - validators = Some(List(MandatoryParameterValidator, CronParameterValidator.delegate)), - label = Some("Schedule"), - hintText = Some("Quartz cron syntax. You can specify multiple schedulers separated by '|'.") - ) - - override def name: String = "flinkPeriodic" - - override def createDeploymentManager( - modelData: BaseModelData, - dependencies: DeploymentManagerDependencies, - config: Config, - scenarioStateCacheTTL: Option[FiniteDuration], - ): ValidatedNel[String, DeploymentManager] = { - logger.info("Creating FlinkPeriodic scenario manager") - delegate.createDeploymentManagerWithCapabilities(modelData, dependencies, config, scenarioStateCacheTTL).map { - delegateDeploymentManager => - import net.ceedubs.ficus.Ficus._ - import net.ceedubs.ficus.readers.ArbitraryTypeReader._ - val periodicBatchConfig = config.as[PeriodicBatchConfig]("deploymentManager") - val flinkConfig = config.rootAs[FlinkConfig] - - PeriodicDeploymentManager( - delegate = delegateDeploymentManager, - schedulePropertyExtractorFactory = _ => CronSchedulePropertyExtractor(), - processConfigEnricherFactory = ProcessConfigEnricherFactory.noOp, - periodicBatchConfig = periodicBatchConfig, - flinkConfig = flinkConfig, - originalConfig = config, - modelData = modelData, - EmptyPeriodicProcessListenerFactory, - DefaultAdditionalDeploymentDataProvider, - dependencies - ) - } - - } - - override def metaDataInitializer(config: Config): MetaDataInitializer = - delegate.metaDataInitializer(config) - - override def scenarioPropertiesConfig(config: Config): Map[String, ScenarioPropertyConfig] = - Map(cronConfig) ++ delegate.scenarioPropertiesConfig(config) - - override def defaultEngineSetupName: EngineSetupName = - delegate.defaultEngineSetupName - - override def engineSetupIdentity(config: Config): Any = - delegate.engineSetupIdentity(config) - -} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/JarManager.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/JarManager.scala deleted file mode 100644 index bf5cab7a4f7..00000000000 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/JarManager.scala +++ /dev/null @@ -1,23 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic - -import pl.touk.nussknacker.engine.api.ProcessVersion -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.deployment.{DeploymentData, ExternalDeploymentId} -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData - -import scala.concurrent.Future - -private[periodic] trait JarManager { - - def prepareDeploymentWithJar( - processVersion: ProcessVersion, - canonicalProcess: CanonicalProcess - ): Future[DeploymentWithJarData.WithCanonicalProcess] - - def deployWithJar( - deploymentWithJarData: DeploymentWithJarData.WithCanonicalProcess, - deploymentData: DeploymentData, - ): Future[Option[ExternalDeploymentId]] - - def deleteJar(jarFileName: String): Future[Unit] -} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SchedulePropertyExtractorFactory.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SchedulePropertyExtractorFactory.scala deleted file mode 100644 index 209f9adb675..00000000000 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/SchedulePropertyExtractorFactory.scala +++ /dev/null @@ -1,7 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic - -import com.typesafe.config.Config - -trait SchedulePropertyExtractorFactory { - def apply(config: Config): SchedulePropertyExtractor -} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/flink/FlinkJarManager.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/flink/FlinkJarManager.scala deleted file mode 100644 index 47b33e7bc93..00000000000 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/flink/FlinkJarManager.scala +++ /dev/null @@ -1,118 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic.flink - -import com.typesafe.scalalogging.LazyLogging -import org.apache.flink.api.common.JobID -import pl.touk.nussknacker.engine.{BaseModelData, newdeployment} -import pl.touk.nussknacker.engine.api.ProcessVersion -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.deployment.{DeploymentData, ExternalDeploymentId} -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData -import pl.touk.nussknacker.engine.management.periodic.{JarManager, PeriodicBatchConfig} -import pl.touk.nussknacker.engine.management.rest.{FlinkClient, HttpFlinkClient} -import pl.touk.nussknacker.engine.management.{ - FlinkConfig, - FlinkDeploymentManager, - FlinkModelJarProvider, - FlinkStreamingRestManager -} -import pl.touk.nussknacker.engine.modelconfig.InputConfigDuringExecution -import sttp.client3.SttpBackend - -import java.nio.file.{Files, Path, Paths} -import scala.concurrent.{ExecutionContext, Future} - -private[periodic] object FlinkJarManager { - - def apply(flinkConfig: FlinkConfig, periodicBatchConfig: PeriodicBatchConfig, modelData: BaseModelData)( - implicit backend: SttpBackend[Future, Any], - ec: ExecutionContext - ): JarManager = { - new FlinkJarManager( - flinkClient = HttpFlinkClient.createUnsafe(flinkConfig), - jarsDir = Paths.get(periodicBatchConfig.jarsDir), - inputConfigDuringExecution = modelData.inputConfigDuringExecution, - modelJarProvider = new FlinkModelJarProvider(modelData.modelClassLoaderUrls) - ) - } - -} - -// Used by [[PeriodicProcessService]]. -private[periodic] class FlinkJarManager( - flinkClient: FlinkClient, - jarsDir: Path, - inputConfigDuringExecution: InputConfigDuringExecution, - modelJarProvider: FlinkModelJarProvider -) extends JarManager - with LazyLogging { - - import scala.concurrent.ExecutionContext.Implicits.global - - override def prepareDeploymentWithJar( - processVersion: ProcessVersion, - canonicalProcess: CanonicalProcess - ): Future[DeploymentWithJarData.WithCanonicalProcess] = { - logger.info(s"Prepare deployment for scenario: $processVersion") - copyJarToLocalDir(processVersion).map { jarFileName => - DeploymentWithJarData.WithCanonicalProcess( - processVersion = processVersion, - process = canonicalProcess, - inputConfigDuringExecutionJson = inputConfigDuringExecution.serialized, - jarFileName = jarFileName - ) - } - } - - private def copyJarToLocalDir(processVersion: ProcessVersion): Future[String] = Future { - jarsDir.toFile.mkdirs() - val jarFileName = - s"${processVersion.processName}-${processVersion.versionId.value}-${System.currentTimeMillis()}.jar" - val jarPath = jarsDir.resolve(jarFileName) - Files.copy(modelJarProvider.getJobJar().toPath, jarPath) - logger.info(s"Copied current model jar to $jarPath") - jarFileName - } - - override def deployWithJar( - deploymentWithJarData: DeploymentWithJarData.WithCanonicalProcess, - deploymentData: DeploymentData - ): Future[Option[ExternalDeploymentId]] = { - val processVersion = deploymentWithJarData.processVersion - logger.info( - s"Deploying scenario ${processVersion.processName}, version id: ${processVersion.versionId} and jar: ${deploymentWithJarData.jarFileName}" - ) - val jarFile = jarsDir.resolve(deploymentWithJarData.jarFileName).toFile - val args = FlinkDeploymentManager.prepareProgramArgs( - deploymentWithJarData.inputConfigDuringExecutionJson, - processVersion, - deploymentData, - deploymentWithJarData.process - ) - flinkClient.runProgram( - jarFile, - FlinkStreamingRestManager.MainClassName, - args, - None, - deploymentData.deploymentId.toNewDeploymentIdOpt.map(toJobId) - ) - } - - override def deleteJar(jarFileName: String): Future[Unit] = { - logger.info(s"Deleting jar: $jarFileName") - for { - _ <- deleteLocalJar(jarFileName) - _ <- flinkClient.deleteJarIfExists(jarFileName) - } yield () - } - - private def deleteLocalJar(jarFileName: String): Future[Unit] = Future { - val jarPath = jarsDir.resolve(jarFileName) - val deleted = Files.deleteIfExists(jarPath) - logger.info(s"Deleted: ($deleted) jar in: $jarPath") - } - - private def toJobId(did: newdeployment.DeploymentId) = { - new JobID(did.value.getLeastSignificantBits, did.value.getMostSignificantBits).toHexString - } - -} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/DeploymentWithJarData.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/DeploymentWithJarData.scala deleted file mode 100644 index 6491b9506b8..00000000000 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/DeploymentWithJarData.scala +++ /dev/null @@ -1,25 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic.model - -import pl.touk.nussknacker.engine.api.ProcessVersion -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess - -sealed trait DeploymentWithJarData { - def processVersion: ProcessVersion - def jarFileName: String -} - -object DeploymentWithJarData { - - final case class WithCanonicalProcess( - processVersion: ProcessVersion, - jarFileName: String, - process: CanonicalProcess, - inputConfigDuringExecutionJson: String, - ) extends DeploymentWithJarData - - final case class WithoutCanonicalProcess( - processVersion: ProcessVersion, - jarFileName: String - ) extends DeploymentWithJarData - -} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/PeriodicProcess.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/PeriodicProcess.scala deleted file mode 100644 index e89deab2320..00000000000 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/PeriodicProcess.scala +++ /dev/null @@ -1,21 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic.model - -import pl.touk.nussknacker.engine.api.ProcessVersion -import pl.touk.nussknacker.engine.api.deployment.ProcessActionId -import pl.touk.nussknacker.engine.management.periodic.ScheduleProperty -import slick.lifted.MappedTo - -import java.time.LocalDateTime - -case class PeriodicProcessId(value: Long) extends MappedTo[Long] - -case class PeriodicProcess[DeploymentData <: DeploymentWithJarData]( - id: PeriodicProcessId, - deploymentData: DeploymentData, - scheduleProperty: ScheduleProperty, - active: Boolean, - createdAt: LocalDateTime, - processActionId: Option[ProcessActionId] -) { - val processVersion: ProcessVersion = deploymentData.processVersion -} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/PeriodicProcessDeployment.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/PeriodicProcessDeployment.scala deleted file mode 100644 index 88f620eff9e..00000000000 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/model/PeriodicProcessDeployment.scala +++ /dev/null @@ -1,52 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic.model - -import pl.touk.nussknacker.engine.management.periodic.{MultipleScheduleProperty, SingleScheduleProperty} -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeploymentStatus.PeriodicProcessDeploymentStatus -import slick.lifted.MappedTo - -import java.time.{Clock, LocalDateTime} - -// TODO: We should separate schedules concept from deployments - fully switch to ScheduleData and ScheduleDeploymentData -case class PeriodicProcessDeployment[DeploymentData <: DeploymentWithJarData]( - id: PeriodicProcessDeploymentId, - periodicProcess: PeriodicProcess[DeploymentData], - createdAt: LocalDateTime, - runAt: LocalDateTime, - scheduleName: ScheduleName, - retriesLeft: Int, - nextRetryAt: Option[LocalDateTime], - state: PeriodicProcessDeploymentState -) { - - def nextRunAt(clock: Clock): Either[String, Option[LocalDateTime]] = - (periodicProcess.scheduleProperty, scheduleName.value) match { - case (MultipleScheduleProperty(schedules), Some(name)) => - schedules.get(name).toRight(s"Failed to find schedule: $scheduleName").flatMap(_.nextRunAt(clock)) - case (e: SingleScheduleProperty, None) => e.nextRunAt(clock) - case (schedule, name) => Left(s"Schedule name: $name mismatch with schedule: $schedule") - } - - def display: String = - s"${periodicProcess.processVersion} with scheduleName=${scheduleName.display} and deploymentId=$id" - -} - -case class PeriodicProcessDeploymentState( - deployedAt: Option[LocalDateTime], - completedAt: Option[LocalDateTime], - status: PeriodicProcessDeploymentStatus -) - -case class PeriodicProcessDeploymentId(value: Long) extends AnyVal with MappedTo[Long] { - override def toString: String = value.toString -} - -object PeriodicProcessDeploymentStatus extends Enumeration { - type PeriodicProcessDeploymentStatus = Value - - val Scheduled, Deployed, Finished, Failed, RetryingDeploy, FailedOnDeploy = Value -} - -case class ScheduleName(value: Option[String]) { - def display: String = value.getOrElse("[default]") -} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/AdditionalDeploymentDataProvider.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/AdditionalDeploymentDataProvider.scala deleted file mode 100644 index 3131908a091..00000000000 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/AdditionalDeploymentDataProvider.scala +++ /dev/null @@ -1,27 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic.service - -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithCanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeployment - -import java.time.format.DateTimeFormatter - -trait AdditionalDeploymentDataProvider { - - def prepareAdditionalData(runDetails: PeriodicProcessDeployment[WithCanonicalProcess]): Map[String, String] - -} - -object DefaultAdditionalDeploymentDataProvider extends AdditionalDeploymentDataProvider { - - override def prepareAdditionalData( - runDetails: PeriodicProcessDeployment[WithCanonicalProcess] - ): Map[String, String] = { - Map( - "deploymentId" -> runDetails.id.value.toString, - "runAt" -> runDetails.runAt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME), - "scheduleName" -> runDetails.scheduleName.value.getOrElse("") - ) - } - -} diff --git a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/PeriodicProcessListener.scala b/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/PeriodicProcessListener.scala deleted file mode 100644 index d3068aac568..00000000000 --- a/engine/flink/management/periodic/src/main/scala/pl/touk/nussknacker/engine/management/periodic/service/PeriodicProcessListener.scala +++ /dev/null @@ -1,61 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic.service - -import com.typesafe.config.Config -import pl.touk.nussknacker.engine.api.deployment.StatusDetails -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.deployment.ExternalDeploymentId -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithCanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.PeriodicProcessDeployment - -/* - Listener is at-least-once. If there are problems e.g. with DB, invocation can be repeated for same event. - Implementation should be aware of that. Listener is invoked during DB transaction, for that reason it's *synchronous* - */ -trait PeriodicProcessListener { - - def onPeriodicProcessEvent: PartialFunction[PeriodicProcessEvent, Unit] - def close(): Unit = {} -} - -trait PeriodicProcessListenerFactory { - def create(config: Config): PeriodicProcessListener -} - -sealed trait PeriodicProcessEvent { - val deployment: PeriodicProcessDeployment[WithCanonicalProcess] -} - -case class DeployedEvent( - deployment: PeriodicProcessDeployment[WithCanonicalProcess], - externalDeploymentId: Option[ExternalDeploymentId] -) extends PeriodicProcessEvent - -case class FinishedEvent( - deployment: PeriodicProcessDeployment[WithCanonicalProcess], - processState: Option[StatusDetails] -) extends PeriodicProcessEvent - -case class FailedOnDeployEvent( - deployment: PeriodicProcessDeployment[WithCanonicalProcess], - processState: Option[StatusDetails] -) extends PeriodicProcessEvent - -case class FailedOnRunEvent( - deployment: PeriodicProcessDeployment[WithCanonicalProcess], - processState: Option[StatusDetails] -) extends PeriodicProcessEvent - -case class ScheduledEvent(deployment: PeriodicProcessDeployment[WithCanonicalProcess], firstSchedule: Boolean) - extends PeriodicProcessEvent - -object EmptyListener extends EmptyListener - -trait EmptyListener extends PeriodicProcessListener { - - override def onPeriodicProcessEvent: PartialFunction[PeriodicProcessEvent, Unit] = Map.empty - -} - -object EmptyPeriodicProcessListenerFactory extends PeriodicProcessListenerFactory { - override def create(config: Config): PeriodicProcessListener = EmptyListener -} diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/JarManagerStub.scala b/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/JarManagerStub.scala deleted file mode 100644 index 4ded31dd0d1..00000000000 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/JarManagerStub.scala +++ /dev/null @@ -1,38 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic - -import pl.touk.nussknacker.engine.api.ProcessVersion -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.deployment.{DeploymentData, ExternalDeploymentId} -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData - -import scala.concurrent.Future - -class JarManagerStub extends JarManager { - - var deployWithJarFuture: Future[Option[ExternalDeploymentId]] = Future.successful(None) - var lastDeploymentWithJarData: Option[DeploymentWithJarData.WithCanonicalProcess] = None - - override def prepareDeploymentWithJar( - processVersion: ProcessVersion, - canonicalProcess: CanonicalProcess - ): Future[DeploymentWithJarData.WithCanonicalProcess] = { - Future.successful( - model.DeploymentWithJarData.WithCanonicalProcess( - processVersion = processVersion, - process = canonicalProcess, - inputConfigDuringExecutionJson = "", - jarFileName = "" - ) - ) - } - - override def deployWithJar( - deploymentWithJarData: DeploymentWithJarData.WithCanonicalProcess, - deploymentData: DeploymentData, - ): Future[Option[ExternalDeploymentId]] = { - lastDeploymentWithJarData = Some(deploymentWithJarData) - deployWithJarFuture - } - - override def deleteJar(jarFileName: String): Future[Unit] = Future.successful(()) -} diff --git a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessGen.scala b/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessGen.scala deleted file mode 100644 index 5d1062d4bb6..00000000000 --- a/engine/flink/management/periodic/src/test/scala/pl/touk/nussknacker/engine/management/periodic/PeriodicProcessGen.scala +++ /dev/null @@ -1,38 +0,0 @@ -package pl.touk.nussknacker.engine.management.periodic - -import pl.touk.nussknacker.engine.api.ProcessVersion -import pl.touk.nussknacker.engine.build.ScenarioBuilder -import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.CronSchedulePropertyExtractor.CronPropertyDefaultName -import pl.touk.nussknacker.engine.management.periodic.model.DeploymentWithJarData.WithCanonicalProcess -import pl.touk.nussknacker.engine.management.periodic.model.{DeploymentWithJarData, PeriodicProcess, PeriodicProcessId} - -import java.time.LocalDateTime - -object PeriodicProcessGen { - - def apply(): PeriodicProcess[WithCanonicalProcess] = { - PeriodicProcess( - id = PeriodicProcessId(42), - deploymentData = DeploymentWithJarData.WithCanonicalProcess( - processVersion = ProcessVersion.empty, - process = buildCanonicalProcess(), - inputConfigDuringExecutionJson = "{}", - jarFileName = "jar-file-name.jar" - ), - scheduleProperty = CronScheduleProperty("0 0 * * * ?"), - active = true, - createdAt = LocalDateTime.now(), - None - ) - } - - def buildCanonicalProcess(cronProperty: String = "0 0 * * * ?"): CanonicalProcess = { - ScenarioBuilder - .streaming("test") - .additionalFields(properties = Map(CronPropertyDefaultName -> cronProperty)) - .source("test", "test") - .emptySink("test", "test") - } - -} diff --git a/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerProviderHelper.scala b/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerProviderHelper.scala index aaa2604efe2..0b9b58fd450 100644 --- a/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerProviderHelper.scala +++ b/engine/flink/management/src/it/scala/pl/touk/nussknacker/engine/management/streaming/FlinkStreamingDeploymentManagerProviderHelper.scala @@ -2,13 +2,14 @@ package pl.touk.nussknacker.engine.management.streaming import akka.actor.ActorSystem import org.asynchttpclient.DefaultAsyncHttpClientConfig +import sttp.client3.asynchttpclient.future.AsyncHttpClientFutureBackend +import pl.touk.nussknacker.engine._ import pl.touk.nussknacker.engine.api.component.DesignerWideComponentId import pl.touk.nussknacker.engine.api.deployment.{ DeploymentManager, NoOpScenarioActivityManager, ProcessingTypeActionServiceStub, - ProcessingTypeDeployedScenariosProviderStub, - ScenarioActivityManager + ProcessingTypeDeployedScenariosProviderStub } import pl.touk.nussknacker.engine.definition.component.Components.ComponentDefinitionExtractionMode import pl.touk.nussknacker.engine.management.FlinkStreamingDeploymentManagerProvider @@ -20,7 +21,6 @@ import pl.touk.nussknacker.engine.{ ModelDependencies, ProcessingTypeConfig } -import sttp.client3.asynchttpclient.future.AsyncHttpClientFutureBackend object FlinkStreamingDeploymentManagerProviderHelper { diff --git a/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkRestManager.scala b/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkRestManager.scala index 3fef55f145e..3f22e5a8d17 100644 --- a/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkRestManager.scala +++ b/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkRestManager.scala @@ -1,9 +1,18 @@ package pl.touk.nussknacker.engine.management +import com.typesafe.config.Config import com.typesafe.scalalogging.LazyLogging import org.apache.flink.api.common.{JobID, JobStatus} import pl.touk.nussknacker.engine.api.ProcessVersion import pl.touk.nussknacker.engine.api.deployment._ +import pl.touk.nussknacker.engine.api.deployment.scheduler._ +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.{ + AdditionalDeploymentDataProvider, + ProcessConfigEnricherFactory, + SchedulePropertyExtractorFactory, + ScheduledExecutionPerformer, + ScheduledProcessListenerFactory +} import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess @@ -11,10 +20,7 @@ import pl.touk.nussknacker.engine.deployment.{DeploymentId, ExternalDeploymentId import pl.touk.nussknacker.engine.management.FlinkRestManager.ParsedJobConfig import pl.touk.nussknacker.engine.management.rest.FlinkClient import pl.touk.nussknacker.engine.management.rest.flinkRestModel.{BaseJobStatusCounts, JobOverview} -import pl.touk.nussknacker.engine.util.WithDataFreshnessStatusUtils.{ - WithDataFreshnessStatusMapOps, - WithDataFreshnessStatusOps -} +import pl.touk.nussknacker.engine.util.WithDataFreshnessStatusUtils.WithDataFreshnessStatusMapOps import pl.touk.nussknacker.engine.{BaseModelData, DeploymentManagerDependencies, newdeployment} import scala.concurrent.Future @@ -71,6 +77,21 @@ class FlinkRestManager( } + override def schedulingSupport: SchedulingSupport = new SchedulingSupported { + + override def createScheduledExecutionPerformer( + modelData: BaseModelData, + dependencies: DeploymentManagerDependencies, + config: Config, + ): ScheduledExecutionPerformer = FlinkScheduledExecutionPerformer.create(modelData, dependencies, config) + + override def customSchedulePropertyExtractorFactory: Option[SchedulePropertyExtractorFactory] = None + override def customProcessConfigEnricherFactory: Option[ProcessConfigEnricherFactory] = None + override def customScheduledProcessListenerFactory: Option[ScheduledProcessListenerFactory] = None + override def customAdditionalDeploymentDataProvider: Option[AdditionalDeploymentDataProvider] = None + + } + private def getAllProcessesStatesFromFlink()( implicit freshnessPolicy: DataFreshnessPolicy ): Future[WithDataFreshnessStatus[Map[ProcessName, List[StatusDetails]]]] = { diff --git a/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkScheduledExecutionPerformer.scala b/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkScheduledExecutionPerformer.scala new file mode 100644 index 00000000000..c312c7834db --- /dev/null +++ b/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkScheduledExecutionPerformer.scala @@ -0,0 +1,140 @@ +package pl.touk.nussknacker.engine.management + +import com.typesafe.config.Config +import com.typesafe.scalalogging.LazyLogging +import org.apache.flink.api.common.JobID +import pl.touk.nussknacker.engine.api.ProcessVersion +import pl.touk.nussknacker.engine.api.deployment.scheduler.model.{DeploymentWithRuntimeParams, RuntimeParams} +import pl.touk.nussknacker.engine.api.deployment.scheduler.services.ScheduledExecutionPerformer +import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess +import pl.touk.nussknacker.engine.deployment.{DeploymentData, ExternalDeploymentId} +import pl.touk.nussknacker.engine.management.FlinkScheduledExecutionPerformer.jarFileNameRuntimeParam +import pl.touk.nussknacker.engine.management.rest.{FlinkClient, HttpFlinkClient} +import pl.touk.nussknacker.engine.modelconfig.InputConfigDuringExecution +import pl.touk.nussknacker.engine.util.config.ConfigEnrichments.RichConfig +import pl.touk.nussknacker.engine.{BaseModelData, DeploymentManagerDependencies, newdeployment} + +import java.nio.file.{Files, Path, Paths} +import scala.concurrent.Future + +object FlinkScheduledExecutionPerformer { + + val jarFileNameRuntimeParam = "jarFileName" + + def create( + modelData: BaseModelData, + dependencies: DeploymentManagerDependencies, + config: Config, + ): ScheduledExecutionPerformer = { + import dependencies._ + import net.ceedubs.ficus.Ficus._ + import net.ceedubs.ficus.readers.ArbitraryTypeReader._ + val flinkConfig = config.rootAs[FlinkConfig] + new FlinkScheduledExecutionPerformer( + flinkClient = HttpFlinkClient.createUnsafe(flinkConfig), + jarsDir = Paths.get(config.getString("scheduling.jarsDir")), + inputConfigDuringExecution = modelData.inputConfigDuringExecution, + modelJarProvider = new FlinkModelJarProvider(modelData.modelClassLoaderUrls) + ) + } + +} + +// Used by [[PeriodicProcessService]]. +class FlinkScheduledExecutionPerformer( + flinkClient: FlinkClient, + jarsDir: Path, + inputConfigDuringExecution: InputConfigDuringExecution, + modelJarProvider: FlinkModelJarProvider +) extends ScheduledExecutionPerformer + with LazyLogging { + + import scala.concurrent.ExecutionContext.Implicits.global + + override def prepareDeploymentWithRuntimeParams( + processVersion: ProcessVersion, + ): Future[DeploymentWithRuntimeParams] = { + logger.info(s"Prepare deployment for scenario: $processVersion") + copyJarToLocalDir(processVersion).map { jarFileName => + DeploymentWithRuntimeParams( + processId = Some(processVersion.processId), + processName = processVersion.processName, + versionId = processVersion.versionId, + runtimeParams = RuntimeParams(Map(jarFileNameRuntimeParam -> jarFileName)) + ) + } + } + + override def provideInputConfigDuringExecutionJson(): Future[InputConfigDuringExecution] = + Future.successful(inputConfigDuringExecution) + + private def copyJarToLocalDir(processVersion: ProcessVersion): Future[String] = Future { + jarsDir.toFile.mkdirs() + val jarFileName = + s"${processVersion.processName}-${processVersion.versionId.value}-${System.currentTimeMillis()}.jar" + val jarPath = jarsDir.resolve(jarFileName) + Files.copy(modelJarProvider.getJobJar().toPath, jarPath) + logger.info(s"Copied current model jar to $jarPath") + jarFileName + } + + override def deployWithRuntimeParams( + deployment: DeploymentWithRuntimeParams, + inputConfigDuringExecutionJson: String, + deploymentData: DeploymentData, + canonicalProcess: CanonicalProcess, + processVersion: ProcessVersion, + ): Future[Option[ExternalDeploymentId]] = { + deployment.runtimeParams.params.get(jarFileNameRuntimeParam) match { + case Some(jarFileName) => + logger.info( + s"Deploying scenario ${deployment.processName}, version id: ${deployment.versionId} and jar: $jarFileName" + ) + val jarFile = jarsDir.resolve(jarFileName).toFile + val args = FlinkDeploymentManager.prepareProgramArgs( + inputConfigDuringExecutionJson, + processVersion, + deploymentData, + canonicalProcess, + ) + flinkClient.runProgram( + jarFile, + FlinkStreamingRestManager.MainClassName, + args, + None, + deploymentData.deploymentId.toNewDeploymentIdOpt.map(toJobId) + ) + case None => + logger.error( + s"Cannot deploy scenario ${deployment.processName}, version id: ${deployment.versionId}: jar file name not present" + ) + Future.successful(None) + } + } + + override def cleanAfterDeployment(runtimeParams: RuntimeParams): Future[Unit] = { + runtimeParams.params.get(jarFileNameRuntimeParam) match { + case Some(jarFileName) => + logger.info(s"Deleting jar: $jarFileName") + for { + _ <- deleteLocalJar(jarFileName) + _ <- flinkClient.deleteJarIfExists(jarFileName) + } yield () + case None => + logger.warn(s"Jar file name not present among runtime params: ${runtimeParams}") + Future.unit + } + + } + + private def deleteLocalJar(jarFileName: String): Future[Unit] = Future { + val jarPath = jarsDir.resolve(jarFileName) + val deleted = Files.deleteIfExists(jarPath) + logger.info(s"Deleted: ($deleted) jar in: $jarPath") + } + + private def toJobId(did: newdeployment.DeploymentId) = { + new JobID(did.value.getLeastSignificantBits, did.value.getMostSignificantBits).toHexString + } + +} diff --git a/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkStreamingDeploymentManagerProvider.scala b/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkStreamingDeploymentManagerProvider.scala index fdc3eef46ac..7081194a2cd 100644 --- a/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkStreamingDeploymentManagerProvider.scala +++ b/engine/flink/management/src/main/scala/pl/touk/nussknacker/engine/management/FlinkStreamingDeploymentManagerProvider.scala @@ -26,15 +26,6 @@ class FlinkStreamingDeploymentManagerProvider extends DeploymentManagerProvider dependencies: DeploymentManagerDependencies, deploymentConfig: Config, scenarioStateCacheTTL: Option[FiniteDuration] - ): ValidatedNel[String, DeploymentManager] = { - createDeploymentManagerWithCapabilities(modelData, dependencies, deploymentConfig, scenarioStateCacheTTL) - } - - def createDeploymentManagerWithCapabilities( - modelData: BaseModelData, - dependencies: DeploymentManagerDependencies, - deploymentConfig: Config, - scenarioStateCacheTTL: Option[FiniteDuration] ): ValidatedNel[String, DeploymentManager] = { import dependencies._ val flinkConfig = deploymentConfig.rootAs[FlinkConfig] diff --git a/engine/flink/management/src/test/scala/pl/touk/nussknacker/engine/management/FlinkRestManagerSpec.scala b/engine/flink/management/src/test/scala/pl/touk/nussknacker/engine/management/FlinkRestManagerSpec.scala index f379f73891b..200458a48ce 100644 --- a/engine/flink/management/src/test/scala/pl/touk/nussknacker/engine/management/FlinkRestManagerSpec.scala +++ b/engine/flink/management/src/test/scala/pl/touk/nussknacker/engine/management/FlinkRestManagerSpec.scala @@ -19,13 +19,7 @@ import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus.Proble import pl.touk.nussknacker.engine.api.process.{ProcessId, ProcessName, VersionId} import pl.touk.nussknacker.engine.api.{MetaData, ProcessVersion, StreamMetaData} import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess -import pl.touk.nussknacker.engine.deployment.{ - AdditionalModelConfigs, - DeploymentData, - DeploymentId, - ExternalDeploymentId, - User -} +import pl.touk.nussknacker.engine.deployment._ import pl.touk.nussknacker.engine.management.rest.HttpFlinkClient import pl.touk.nussknacker.engine.management.rest.flinkRestModel._ import pl.touk.nussknacker.engine.testing.LocalModelData diff --git a/engine/lite/embeddedDeploymentManager/src/main/scala/pl/touk/nussknacker/engine/embedded/EmbeddedDeploymentManager.scala b/engine/lite/embeddedDeploymentManager/src/main/scala/pl/touk/nussknacker/engine/embedded/EmbeddedDeploymentManager.scala index 785d01b97b8..9603698ddf5 100644 --- a/engine/lite/embeddedDeploymentManager/src/main/scala/pl/touk/nussknacker/engine/embedded/EmbeddedDeploymentManager.scala +++ b/engine/lite/embeddedDeploymentManager/src/main/scala/pl/touk/nussknacker/engine/embedded/EmbeddedDeploymentManager.scala @@ -257,6 +257,8 @@ class EmbeddedDeploymentManager( override def stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport = NoStateQueryForAllScenariosSupport + override def schedulingSupport: SchedulingSupport = NoSchedulingSupport + override def processStateDefinitionManager: ProcessStateDefinitionManager = EmbeddedProcessStateDefinitionManager override def close(): Unit = { diff --git a/engine/lite/embeddedDeploymentManager/src/test/scala/pl/touk/nussknacker/streaming/embedded/RequestResponseEmbeddedDeploymentManagerTest.scala b/engine/lite/embeddedDeploymentManager/src/test/scala/pl/touk/nussknacker/streaming/embedded/RequestResponseEmbeddedDeploymentManagerTest.scala index 0d28fae5775..d3011301eef 100644 --- a/engine/lite/embeddedDeploymentManager/src/test/scala/pl/touk/nussknacker/streaming/embedded/RequestResponseEmbeddedDeploymentManagerTest.scala +++ b/engine/lite/embeddedDeploymentManager/src/test/scala/pl/touk/nussknacker/streaming/embedded/RequestResponseEmbeddedDeploymentManagerTest.scala @@ -7,20 +7,9 @@ import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import pl.touk.nussknacker.engine.api.ProcessVersion import pl.touk.nussknacker.engine.api.deployment.DeploymentUpdateStrategy.StateRestoringStrategy +import pl.touk.nussknacker.engine.api.deployment._ import pl.touk.nussknacker.engine.api.deployment.cache.ScenarioStateCachingConfig import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus -import pl.touk.nussknacker.engine.api.deployment.{ - DMCancelScenarioCommand, - DMRunDeploymentCommand, - DataFreshnessPolicy, - DeployedScenarioData, - DeploymentManager, - DeploymentUpdateStrategy, - NoOpScenarioActivityManager, - ProcessingTypeActionServiceStub, - ProcessingTypeDeployedScenariosProviderStub, - ScenarioActivityManager -} import pl.touk.nussknacker.engine.api.process.ProcessName import pl.touk.nussknacker.engine.build.ScenarioBuilder import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess diff --git a/engine/lite/k8sDeploymentManager/src/main/scala/pl/touk/nussknacker/k8s/manager/K8sDeploymentManager.scala b/engine/lite/k8sDeploymentManager/src/main/scala/pl/touk/nussknacker/k8s/manager/K8sDeploymentManager.scala index 8357f2a449d..5c83b0b9aa1 100644 --- a/engine/lite/k8sDeploymentManager/src/main/scala/pl/touk/nussknacker/k8s/manager/K8sDeploymentManager.scala +++ b/engine/lite/k8sDeploymentManager/src/main/scala/pl/touk/nussknacker/k8s/manager/K8sDeploymentManager.scala @@ -389,6 +389,7 @@ class K8sDeploymentManager( override def stateQueryForAllScenariosSupport: StateQueryForAllScenariosSupport = NoStateQueryForAllScenariosSupport + override def schedulingSupport: SchedulingSupport = NoSchedulingSupport } object K8sDeploymentManager { diff --git a/engine/lite/k8sDeploymentManager/src/test/scala/pl/touk/nussknacker/k8s/manager/BaseK8sDeploymentManagerTest.scala b/engine/lite/k8sDeploymentManager/src/test/scala/pl/touk/nussknacker/k8s/manager/BaseK8sDeploymentManagerTest.scala index 05b006824bc..b81fdaf74d3 100644 --- a/engine/lite/k8sDeploymentManager/src/test/scala/pl/touk/nussknacker/k8s/manager/BaseK8sDeploymentManagerTest.scala +++ b/engine/lite/k8sDeploymentManager/src/test/scala/pl/touk/nussknacker/k8s/manager/BaseK8sDeploymentManagerTest.scala @@ -9,8 +9,8 @@ import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import pl.touk.nussknacker.engine.api.ProcessVersion import pl.touk.nussknacker.engine.api.deployment.DeploymentUpdateStrategy.StateRestoringStrategy -import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.api.deployment._ +import pl.touk.nussknacker.engine.api.deployment.simple.SimpleStateStatus import pl.touk.nussknacker.engine.canonicalgraph.CanonicalProcess import pl.touk.nussknacker.engine.deployment.DeploymentData import pl.touk.nussknacker.engine.{DeploymentManagerDependencies, ModelData} diff --git a/engine/lite/k8sDeploymentManager/src/test/scala/pl/touk/nussknacker/k8s/manager/K8sDeploymentManagerOnMocksTest.scala b/engine/lite/k8sDeploymentManager/src/test/scala/pl/touk/nussknacker/k8s/manager/K8sDeploymentManagerOnMocksTest.scala index 16b645f8582..309a7bc25fe 100644 --- a/engine/lite/k8sDeploymentManager/src/test/scala/pl/touk/nussknacker/k8s/manager/K8sDeploymentManagerOnMocksTest.scala +++ b/engine/lite/k8sDeploymentManager/src/test/scala/pl/touk/nussknacker/k8s/manager/K8sDeploymentManagerOnMocksTest.scala @@ -13,9 +13,7 @@ import pl.touk.nussknacker.engine.api.deployment.{ DataFreshnessPolicy, NoOpScenarioActivityManager, ProcessingTypeActionServiceStub, - ProcessingTypeDeployedScenariosProvider, - ProcessingTypeDeployedScenariosProviderStub, - ScenarioActivityManager + ProcessingTypeDeployedScenariosProviderStub } import pl.touk.nussknacker.engine.api.process.ProcessName import pl.touk.nussknacker.engine.testing.LocalModelData diff --git a/nussknacker-dist/src/universal/conf/dev-application.conf b/nussknacker-dist/src/universal/conf/dev-application.conf index ddf3c4c27aa..f795c859b07 100644 --- a/nussknacker-dist/src/universal/conf/dev-application.conf +++ b/nussknacker-dist/src/universal/conf/dev-application.conf @@ -159,16 +159,15 @@ scenarioTypes { } "periodic-dev": { deploymentConfig: { - type: "flinkPeriodic" + type: "flinkStreaming" + scheduling { + enabled: true + processingType: streaming + jarsDir: ${storageDir}/jars + } restUrl: "http://jobmanager:8081" restUrl: ${?FLINK_REST_URL} shouldVerifyBeforeDeploy: ${?FLINK_SHOULD_VERIFY_BEFORE_DEPLOY} - deploymentManager { - db: ${db}, - db.table: "periodic_flyway_schema_history" - processingType: streaming, - jarsDir: ${storageDir}/jars - } } modelConfig: { classPath: ["model/devModel.jar", "model/flinkExecutor.jar", "components/flink", "components/common", "flink-dropwizard-metrics-deps/"] From e69c74b34fc1c298ab363f1cf497e1ac31c5f02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Czajka?= Date: Fri, 17 Jan 2025 09:53:11 +0100 Subject: [PATCH 10/10] [NU-1935] Change record indexer behaviour (#7443) Co-authored-by: Pawel Czajka --- docs/Changelog.md | 1 + .../spel/SpelExpressionParseError.scala | 4 ++++ .../touk/nussknacker/engine/spel/Typer.scala | 12 ++++++++--- .../engine/spel/SpelExpressionSpec.scala | 9 ++++++++ .../nussknacker/engine/spel/TyperSpec.scala | 21 ++++++++++++++----- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/Changelog.md b/docs/Changelog.md index 370f3e7431d..1d669514b45 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -58,6 +58,7 @@ * [#7364](https://github.com/TouK/nussknacker/pull/7364) PeriodicDeploymentManger is no longer a separate DM, but instead is an optional functionality and decorator for all DMs * in order to use it, DM must implement interface `schedulingSupported`, that handles deployments on a specific engine * implementation provided for Flink DM +* [#7443](https://github.com/TouK/nussknacker/pull/7443) Indexing on record is more similar to indexing on map. The change lets us access record values dynamically. For example now spel expression "{a: 5, b: 10}[#input.field]" compiles and has type "Integer" inferred from types of values of the record. This lets us access record value based on user input, for instance if user passes "{"field": "b"}" to scenario we will get value "10", whereas input {"field": "c"} would result in "null". Expression "{a: 5}["b"]" still does not compile because it is known at compile time that record does not have property "b". ## 1.18 diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionParseError.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionParseError.scala index 49b27a7f186..5182bd6bae6 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionParseError.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionParseError.scala @@ -106,6 +106,10 @@ object SpelExpressionParseError { override def message: String = s"There is no property '$property' in type: ${typ.display}" } + case class NoPropertyTypeError(typ: TypingResult, propertyType: TypingResult) extends MissingObjectError { + override def message: String = s"There is no property of type '${propertyType.display}' in type: ${typ.display}" + } + case class UnknownMethodError(methodName: String, displayableType: String) extends MissingObjectError { override def message: String = s"Unknown method '$methodName' in $displayableType" } diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/Typer.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/Typer.scala index 94398a81cd6..995a8b6afe6 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/Typer.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/Typer.scala @@ -26,6 +26,7 @@ import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.IllegalOperation import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.MissingObjectError.{ ConstructionOfUnknown, NoPropertyError, + NoPropertyTypeError, NonReferenceError, UnresolvedReferenceError } @@ -199,11 +200,16 @@ private[spel] class Typer( case _ => typeFieldNameReferenceOnRecord(indexString, record) } case indexKey :: Nil if indexKey.canBeConvertedTo(Typed[String]) => - if (dynamicPropertyAccessAllowed) valid(Unknown) else invalid(DynamicPropertyAccessError) - case _ :: Nil => + if (dynamicPropertyAccessAllowed) valid(Unknown) + else + record.runtimeObjType.params match { + case _ :: value :: Nil if record.runtimeObjType.klass == classOf[java.util.Map[_, _]] => valid(value) + case _ => valid(Unknown) + } + case e :: Nil => indexer.children match { case (ref: PropertyOrFieldReference) :: Nil => typeFieldNameReferenceOnRecord(ref.getName, record) - case _ => if (dynamicPropertyAccessAllowed) valid(Unknown) else invalid(DynamicPropertyAccessError) + case _ => if (dynamicPropertyAccessAllowed) valid(Unknown) else invalid(NoPropertyTypeError(record, e)) } case _ => invalid(IllegalIndexingOperation) diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala index 6291e7c8ff4..3455fad4c08 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala @@ -272,6 +272,15 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD private def evaluate[T: TypeTag](expr: String, context: Context = ctx): T = parse[T](expr = expr, context = context).validExpression.evaluateSync[T](context) + test("should be able to dynamically index record") { + evaluate[Int]("{a: 5, b: 10}[#input.toString()]", Context("abc").withVariable("input", "a")) shouldBe 5 + evaluate[Integer]("{a: 5, b: 10}[#input.toString()]", Context("abc").withVariable("input", "asdf")) shouldBe null + } + + test("should figure out result type when dynamically indexing record") { + evaluate[Int]("{a: {g: 5, h: 10}, b: {g: 50, h: 100}}[#input.toString()].h", Context("abc").withVariable("input", "b")) shouldBe 100 + } + test("parsing first selection on array") { parse[Any]("{1,2,3,4,5,6,7,8,9,10}.^[(#this%2==0)]").validExpression .evaluateSync[java.util.ArrayList[Int]](ctx) should equal(2) diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/TyperSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/TyperSpec.scala index f69278af040..9da82310d9f 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/TyperSpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/TyperSpec.scala @@ -13,8 +13,10 @@ import pl.touk.nussknacker.engine.api.typed.typing._ import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionTestUtils import pl.touk.nussknacker.engine.dict.{KeysDictTyper, SimpleDictRegistry} import pl.touk.nussknacker.engine.expression.PositionRange -import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.IllegalOperationError.DynamicPropertyAccessError -import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.MissingObjectError.NoPropertyError +import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.MissingObjectError.{ + NoPropertyError, + NoPropertyTypeError +} import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.UnsupportedOperationError.MapWithExpressionKeysError import pl.touk.nussknacker.engine.spel.Typer.TypingResultWithContext import pl.touk.nussknacker.engine.spel.TyperSpecTestData.TestRecord._ @@ -162,10 +164,8 @@ class TyperSpec extends AnyFunSuite with Matchers with ValidatedValuesDetailedMe typeExpression(s"$testRecordExpr[#var]", "var" -> s"$nonPresentKey").invalidValue.toList should matchPattern { case NoPropertyError(typingResult, key) :: Nil if typingResult == testRecordTyped && key == nonPresentKey => } - // TODO: this behavior is to be fixed - ideally this should behave the same as above typeExpression(s"$testRecordExpr[$nonPresentKey]").invalidValue.toList should matchPattern { - case NoPropertyError(typingResult, key) :: DynamicPropertyAccessError :: Nil - if typingResult == testRecordTyped && key == nonPresentKey => + case NoPropertyError(typingResult, key) :: Nil if typingResult == testRecordTyped && key == nonPresentKey => } } @@ -183,6 +183,17 @@ class TyperSpec extends AnyFunSuite with Matchers with ValidatedValuesDetailedMe } } + test("indexing on records with key which is not known at compile time treats record as map") { + typeExpression("{a: 5, b: 10}[#var.toString()]", "var" -> "a").validValue.finalResult.typingResult shouldBe Typed + .typedClass[Int] + } + + test("indexing on records with non string key produces error") { + typeExpression("{a: 5, b: 10}[4]").invalidValue.toList should matchPattern { + case NoPropertyTypeError(_, _) :: Nil => + } + } + private def buildTyper(dynamicPropertyAccessAllowed: Boolean = false) = new Typer( dictTyper = new KeysDictTyper(new SimpleDictRegistry(Map.empty)), strictMethodsChecking = false,