Compare commits

...

66 Commits

Author SHA1 Message Date
fatonramadani
76266f3efb Apply automatic changes 2024-01-08 10:37:39 +00:00
Faton Ramadani
2cdda619de Merge branch 'main' into operator-menu 2024-01-08 11:35:59 +01:00
Ruben Fiszel
d340fbc02f fix: delete is captured in more fields in app 2024-01-08 11:33:28 +01:00
Ruben Fiszel
bed2d1f417 chore: use dev instead of latest to publish to ecr 2024-01-08 10:12:24 +01:00
Ruben Fiszel
4377cb3bcc chore(main): release 1.236.2 (#2970)
* chore(main): release 1.236.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-01-08 10:05:25 +01:00
Ruben Fiszel
269afe41f7 fix: handle better non object result for app result loader 2024-01-08 10:02:24 +01:00
Ruben Fiszel
1d1f03691a chore(main): release 1.236.1 (#2969)
* chore(main): release 1.236.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-01-08 09:29:56 +01:00
Ruben Fiszel
6b075e92ef fix: fix app initialization 2024-01-08 09:27:50 +01:00
Ruben Fiszel
be28522eb7 chore(main): release 1.236.0 (#2968)
* chore(main): release 1.236.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-01-08 06:49:31 +01:00
Ruben Fiszel
5749a3083a fix script deployment 2024-01-08 06:37:08 +01:00
Ruben Fiszel
2034aebb17 fix tests following parser update 2024-01-08 00:57:11 +01:00
Ruben Fiszel
4384617f1e fix: reduce stack size needed for workflow transitions 2024-01-08 00:47:59 +01:00
Ruben Fiszel
94493812ac feat: improve git sync and recompute dependents when common python script is redeployed (#2967)
* fix: allow extraConfig for agGrid to set onGridReady

* depmap

* depmap

* depmap

* depmap
2024-01-07 15:18:27 +01:00
Ruben Fiszel
2fc21d46fa chore(main): release 1.235.1 (#2966)
* chore(main): release 1.235.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-01-07 14:15:40 +01:00
Ruben Fiszel
2e023003da fix: fix resultjobloader 2024-01-07 14:12:20 +01:00
Guillaume Bouvignies
30a30f81e0 chore: Update git sync scripts (#2965) 2024-01-06 18:23:01 +01:00
Ruben Fiszel
723744e41e chore(main): release 1.235.0 (#2964)
* chore(main): release 1.235.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-01-06 17:32:41 +01:00
Faton Ramadani
fffc5338ce feat(frontend): DB Explorer (#2892)
* feat(frontend): Make table cell editable

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): clean up

* fix(frontend): extract db schemas

* feat(frontend): v0 done

* feat(frontend): v0 done

* feat(frontend): v0 done

* feat(frontend): fix insert

* feat(frontend): fix insert

* feat(frontend): remove temp data

* feat(frontend): align insert button to the right

* feat(frontend): rework columns

* feat(frontend): rework insert

* feat(frontend): rework insert

* feat(frontend): rework insert

* feat(frontend): rework insert

* feat(frontend): fix jsonb display

* feat(frontend): fix reloading issues

* feat(frontend): fix reloading issues

* feat(frontend): fix reacticity issue

* feat(frontend): fix insert

* feat(frontend): support nullable default value

* feat(frontend): fix build

* update

* update

* db studio v0

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2024-01-06 17:27:15 +01:00
Henri Courdent
3c2f753eac "Author" to "Developer", Premium plans tab, Flow inputs (#2952)
* Premium plans tab & Flow inputs

* Fix build
2024-01-06 16:55:43 +01:00
Ruben Fiszel
aa3d802137 chore(main): release 1.234.1 (#2962)
* chore(main): release 1.234.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-01-05 18:30:26 +01:00
Ruben Fiszel
9efcb53bcd fix: fix double view runs button 2024-01-05 18:27:45 +01:00
Ruben Fiszel
4385edc6ff fix: fix typo "Wourker" -> "Worker" 2024-01-05 18:26:52 +01:00
Ruben Fiszel
3cadaa3f2b chore(main): release 1.234.0 (#2957)
* chore(main): release 1.234.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-01-05 18:24:22 +01:00
Guillaume Bouvignies
c05d119ae5 fix: Deleting workspace delete all workspace related values in DB (#2961)
* fix: Deleting workspace delete all workspace related values in DB

* sqlx prepare
2024-01-05 18:02:40 +01:00
Guillaume Bouvignies
fb5cd6a429 feat: git sync can now push commits to individual branches (#2959) 2024-01-05 15:52:18 +01:00
Guillaume Bouvignies
0710ce6ef2 feat: Detailed job memory footprint on job run page (#2956)
* feat: Detailed job memory footprint

* Address rubens comment

* double presion -> real

* sql query returns only metric_type
2024-01-05 09:13:05 +01:00
Martin Jul
8619f3102d Fix typo in README.md (#2955)
Fix typo
2024-01-04 11:24:33 +01:00
Guillaume Bouvignies
31d48533f4 S3 functions in Python SDK cannot return None (#2954) 2024-01-03 17:26:25 +01:00
Ruben Fiszel
6cd46ca7f8 chore(main): release 1.233.0 (#2926)
* chore(main): release 1.233.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2024-01-03 13:20:20 +01:00
Faton Ramadani
2494171b9c fix(frontend): handle case where large json has a file and a filename (#2951) 2024-01-03 09:17:30 +01:00
Guillaume Bouvignies
965571853c fix: Tag all docker images with latest and main tags (#2953) 2024-01-03 01:38:33 +09:00
Faton Ramadani
37744ff583 Merge branch 'main' into operator-menu 2024-01-02 06:38:21 +01:00
Faton Ramadani
dbe14757d4 Merge branch 'main' of https://github.com/windmill-labs/windmill 2024-01-02 06:38:12 +01:00
Ruben Fiszel
486b3aeb17 chore: improve e2e 2023-12-30 05:25:10 +01:00
Ruben Fiszel
5ee0040840 chore: improve e2e 2023-12-30 04:47:08 +01:00
Ruben Fiszel
0f774b4485 chore: improve e2e 2023-12-30 04:43:51 +01:00
Ruben Fiszel
0c40c1324b chore: improve integration tests 2023-12-30 04:21:34 +01:00
Ruben Fiszel
b17652ae60 chore: improve integration tests 2023-12-30 03:59:20 +01:00
Guillaume Bouvignies
b44618f35b feat: Job caching now works with S3 files (#2934) 2023-12-30 03:40:05 +01:00
Faton Ramadani
821d2d7ea1 feat(frontend): add the view runs buttons for operators (#2932)
* feat(frontend): add the view runs buttons for operators

* Update +page.svelte

* Update +page.svelte

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2023-12-30 03:39:22 +01:00
Ruben Fiszel
0299c656c3 fix: decision tree fix wording (#2941)
* fix(frontend): Fix decision tree (#2928)

* fix(frontend): wip

* fix(frontend): wip

* fix(frontend): decision tree history

* fix(frontend): fix wording

* fix(frontend): fix wording

* fix(frontend): fix wording

* Carousel link and trigger frontend (#2929)

Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>

---------

Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>
Co-authored-by: Henri Courdent <122811744+hcourdent@users.noreply.github.com>
2023-12-30 03:39:05 +01:00
Ruben Fiszel
e0140f175d feat: PIP_LOCAL_DEPENDENCIES now accepts regex and is configurable from the UI (#2940)
* fix(frontend): Fix decision tree (#2928)

* fix(frontend): wip

* fix(frontend): wip

* fix(frontend): decision tree history

* fix(frontend): fix wording

* feat: PIP_LOCAL_DEPENDENCIES now accepts regex

* Add worker config FE

---------

Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>
Co-authored-by: gbouv <guillaume@windmill.dev>
2023-12-30 03:35:04 +01:00
Ruben Fiszel
65ad840dc4 docs: Carousel link and trigger frontend (#2939)
* Carousel link and trigger frontend

* fix(frontend): Fix decision tree (#2928)

* fix(frontend): wip

* fix(frontend): wip

* fix(frontend): decision tree history

* fix(frontend): fix wording

---------

Co-authored-by: hcourdent <henri@windmill.dev>
Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>
2023-12-30 03:32:14 +01:00
Ruben Fiszel
f13fb4505c docs: Example on how to monitor Windmill (#2938)
* docs: Example on how to track arbitrary job metrics

* Add how to monitor Windmill servers and workers

* Update README.md

---------

Co-authored-by: gbouv <guillaume@windmill.dev>
2023-12-30 03:30:48 +01:00
Ruben Fiszel
c80e4c5ffd chore: eetest (#2937)
* chore: Windmill integration tests

* Revert github workflow temp changes

* fix ee

---------

Co-authored-by: gbouv <guillaume@windmill.dev>
2023-12-30 03:29:00 +01:00
Ruben Fiszel
787017f53c fix: decision tree nits (#2936)
* fix(frontend): wip

* fix(frontend): wip

* fix(frontend): decision tree history

* fix(frontend): fix wording

---------

Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>
2023-12-30 03:06:37 +01:00
Faton Ramadani
12bd4af3ee Merge branch 'main' into operator-menu 2023-12-29 16:54:12 +01:00
Faton Ramadani
9cdaf9c201 feat(frontend): wip operator menu 2023-12-29 16:52:28 +01:00
Faton Ramadani
ec094e8897 feat(frontend): wip operator menu 2023-12-29 16:48:09 +01:00
Faton Ramadani
cf61e09b90 feat(frontend): wip operator menu 2023-12-29 16:22:02 +01:00
Faton Ramadani
6a0ae83b49 feat(frontend): wip operator menu 2023-12-29 15:44:08 +01:00
Faton Ramadani
2f4be74e94 feat(frontend): wip operator menu 2023-12-29 15:42:03 +01:00
Guillaume Bouvignies
28811023a4 feat: PIP_LOCAL_DEPENDENCIES now accepts regex and is configurable from the UI (#2931)
* feat: PIP_LOCAL_DEPENDENCIES now accepts regex

* Add worker config FE
2023-12-29 22:02:46 +09:00
Faton Ramadani
e80534ca65 fix(frontend): fix wording (#2930)
* fix(frontend): fix wording

* fix(frontend): fix wording
2023-12-29 21:58:06 +09:00
Henri Courdent
95be0155dc Carousel link and trigger frontend (#2929)
Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com>
2023-12-29 12:24:00 +01:00
Faton Ramadani
004781cd3d fix(frontend): Fix decision tree (#2928)
* fix(frontend): wip

* fix(frontend): wip

* fix(frontend): decision tree history

* fix(frontend): fix wording
2023-12-29 03:33:23 +01:00
Ruben Fiszel
c085f5fa7e fix: persist state for agGrid 2023-12-29 03:28:07 +01:00
Ruben Fiszel
ad6c3e5cee small UX nits 2023-12-29 03:21:11 +01:00
Ruben Fiszel
74fc2d3f43 fix: allow extraConfig for agGrid to set onGridReady 2023-12-27 21:20:49 +01:00
Ruben Fiszel
3d1f707f36 chore(main): release 1.232.0 (#2924)
* chore(main): release 1.232.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <rubenfiszel@users.noreply.github.com>
2023-12-27 15:04:51 +01:00
Guillaume Bouvignies
d316af993d chore: update snippets for Polars 0.20 (#2925) 2023-12-27 15:02:06 +01:00
Ruben Fiszel
263f41cda3 fix: fix file input 2023-12-27 14:55:01 +01:00
Ruben Fiszel
b66fcbf626 fix: improve ata for bun 2023-12-27 02:29:38 +01:00
Ruben Fiszel
948b0da59e fix: use bun install in every case 2023-12-27 00:05:46 +01:00
Ruben Fiszel
49355280f6 feat: no overlap for flows togglable on schedules 2023-12-26 16:31:04 +01:00
Ruben Fiszel
606b03d761 feat: add summary to schedules 2023-12-26 14:15:46 +01:00
214 changed files with 9989 additions and 2207 deletions

25
.github/change-versions-mac.sh vendored Executable file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -euo pipefail
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root_dirpath="$(cd "${script_dirpath}/.." && pwd)"
VERSION=$1
echo "Updating versions to: $VERSION"
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/main.ts
sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml
sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
sed -i '' -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/package.json
sed -i '' -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i '' -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill_pg/pyproject.toml
# sed -i '' -e "/^wmill =/s/= .*/= \"\\^$VERSION\"/" python-client/wmill_pg/pyproject.toml
sed -i '' -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i '' -e "/^wmill_pg =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i '' -E "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
cd ${root_dirpath}/frontend && npm i --package-lock-only

View File

@@ -1,23 +1,25 @@
#!/bin/bash
set -euo pipefail
script_dirpath="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root_dirpath="$(cd "${script_dirpath}/.." && pwd)"
VERSION=$1
echo "Updating versions to: $VERSION"
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" backend/Cargo.toml
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" cli/main.ts
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" benchmarks/lib.ts
sed -i -e "/version: /s/: .*/: $VERSION/" backend/windmill-api/openapi.yaml
sed -i -e "/version: /s/: .*/: $VERSION/" openflow.openapi.yaml
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" typescript-client/package.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" frontend/package.json
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" python-client/wmill/pyproject.toml
sed -i -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" python-client/wmill/pyproject.toml
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" python-client/wmill_pg/pyproject.toml
# sed -i -e "/^wmill =/s/= .*/= \"\\^$VERSION\"/" python-client/wmill_pg/pyproject.toml
sed -i -e "/^wmill =/s/= .*/= \">=$VERSION\"/" lsp/Pipfile
sed -i -e "/^wmill_pg =/s/= .*/= \">=$VERSION\"/" lsp/Pipfile
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/main.ts
sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts
sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml
sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/typescript-client/package.json
sed -i -e "/\"version\": /s/: .*,/: \"$VERSION\",/" ${root_dirpath}/frontend/package.json
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i -e "/^windmill-api =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill/pyproject.toml
sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/python-client/wmill_pg/pyproject.toml
# sed -i -e "/^wmill =/s/= .*/= \"\\^$VERSION\"/" ${root_dirpath}/python-client/wmill_pg/pyproject.toml
sed -i -e "/^wmill =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i -e "/^wmill_pg =/s/= .*/= \">=$VERSION\"/" ${root_dirpath}/lsp/Pipfile
sed -i -zE "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" backend/Cargo.lock
sed -i -zE "s/name = \"windmill\"\nversion = \"[^\"]*\"\\n(.*)/name = \"windmill\"\nversion = \"$VERSION\"\\n\\1/" ${root_dirpath}/backend/Cargo.lock
cd frontend && npm i --package-lock-only
cd ${root_dirpath}/frontend && npm i --package-lock-only

View File

@@ -43,8 +43,9 @@ jobs:
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
@@ -56,7 +57,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
${{ steps.meta-public.outputs.tags }}
labels: |
${{ steps.meta-public.outputs.labels }}
@@ -79,8 +80,9 @@ jobs:
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee
flavor: |
latest=false
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
@@ -102,7 +104,7 @@ jobs:
features=enterprise,enterprise_saml
nsjail=true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
${{ steps.meta-ee-public.outputs.tags }}
labels: |
${{ steps.meta-ee-public.outputs.labels }}
@@ -198,25 +200,25 @@ jobs:
- run: |
# pulling docker image with desired arch so that actions-docker-extract doesn't do it
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
- run: |
# Checks the image is in docker prior to running actions-docker-extract. It fails if not
# Also useful to visually check that the arch is the right opencontainers
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
- uses: shrink/actions-docker-extract@v3
id: extract
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
path: "/usr/src/app/windmill"
- uses: shrink/actions-docker-extract@v3
id: extract-ee
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
path: "/usr/src/app/windmill"
- name: Rename binary with corresponding architecture
@@ -242,25 +244,25 @@ jobs:
- run: |
# pulling docker image with desired arch so that actions-docker-extract doesn't do it
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
- run: |
# Checks the image is in docker prior to running actions-docker-extract. It fails if not
# Also useful to visually check that the arch is the right opencontainers
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
- uses: shrink/actions-docker-extract@v3
id: extract
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
path: "/usr/src/app/windmill"
- uses: shrink/actions-docker-extract@v3
id: extract-ee
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
path: "/usr/src/app/windmill"
- name: Rename binary with corresponding architecture
@@ -305,7 +307,7 @@ jobs:
git_hash=$(git rev-parse --short "$GITHUB_SHA")
docker buildx imagetools create \
--tag ${{ env.ECR_REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${git_hash:0:7} \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
echo "GIT_HASH=${git_hash:0:7}" >> "$GITHUB_OUTPUT"
- uses: shrink/actions-docker-extract@v3
@@ -322,3 +324,63 @@ jobs:
folder: ${{ steps.extract.outputs.destination }}
bucket: windmill-frontend
bucket-region: us-east-1
run_integration_test:
runs-on: ubicloud
needs: [build_ee]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Prepare test run
if: ${{ ! startsWith(github.ref, 'refs/tags/') }}
run: cd integration_tests && ./build.sh
- name: Test run
if: ${{ ! startsWith(github.ref, 'refs/tags/') }}
timeout-minutes: 15
env:
LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }}
run: cd integration_tests && ./run.sh
- name: Archive logs
uses: actions/upload-artifact@v3
if: always()
with:
name: Windmill Integration Tests Logs
path: |
integration_tests/logs
tag_latest:
runs-on: ubicloud
needs: [run_integration_test]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Login to registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag main and latest
run: |
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
tag_latest_ee:
runs-on: ubicloud
needs: [run_integration_test]
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Login to registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Tag main and latest for ee
run: |
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest
docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main

View File

@@ -1,6 +1,101 @@
# Changelog
## [1.236.2](https://github.com/windmill-labs/windmill/compare/v1.236.1...v1.236.2) (2024-01-08)
### Bug Fixes
* handle better non object result for app result loader ([269afe4](https://github.com/windmill-labs/windmill/commit/269afe41f7e02da8d3a364e649e5fef00847d24a))
## [1.236.1](https://github.com/windmill-labs/windmill/compare/v1.236.0...v1.236.1) (2024-01-08)
### Bug Fixes
* fix app initialization ([6b075e9](https://github.com/windmill-labs/windmill/commit/6b075e92ef4e312e6a4ce582dc7c4d88456b2f5d))
## [1.236.0](https://github.com/windmill-labs/windmill/compare/v1.235.1...v1.236.0) (2024-01-08)
### Features
* improve git sync and recompute dependents when common python script is redeployed ([#2967](https://github.com/windmill-labs/windmill/issues/2967)) ([9449381](https://github.com/windmill-labs/windmill/commit/94493812ac0030cc6bba468d8ee24a14661716a8))
### Bug Fixes
* reduce stack size needed for workflow transitions ([4384617](https://github.com/windmill-labs/windmill/commit/4384617f1ec4c27de88ef6c895d6ef060a79f6bd))
## [1.235.1](https://github.com/windmill-labs/windmill/compare/v1.235.0...v1.235.1) (2024-01-07)
### Bug Fixes
* fix resultjobloader ([2e02300](https://github.com/windmill-labs/windmill/commit/2e023003da8bea9efa86c0fbc52bf9723c73907d))
## [1.235.0](https://github.com/windmill-labs/windmill/compare/v1.234.1...v1.235.0) (2024-01-06)
### Features
* **frontend:** DB Explorer ([#2892](https://github.com/windmill-labs/windmill/issues/2892)) ([fffc533](https://github.com/windmill-labs/windmill/commit/fffc5338ce76fda9a68b19f03cefe199cc09a905))
## [1.234.1](https://github.com/windmill-labs/windmill/compare/v1.234.0...v1.234.1) (2024-01-05)
### Bug Fixes
* fix double view runs button ([9efcb53](https://github.com/windmill-labs/windmill/commit/9efcb53bcd903bf756c4beb92a5c7634e8e016ad))
* fix typo "Wourker" -&gt; "Worker" ([4385edc](https://github.com/windmill-labs/windmill/commit/4385edc6ffdb1b2e4544347928b4cf08faf1eaa1))
## [1.234.0](https://github.com/windmill-labs/windmill/compare/v1.233.0...v1.234.0) (2024-01-05)
### Features
* Detailed job memory footprint on job run page ([#2956](https://github.com/windmill-labs/windmill/issues/2956)) ([0710ce6](https://github.com/windmill-labs/windmill/commit/0710ce6ef25d8f2344ac1350e96aa906f16d1be1))
* git sync can now push commits to individual branches ([#2959](https://github.com/windmill-labs/windmill/issues/2959)) ([fb5cd6a](https://github.com/windmill-labs/windmill/commit/fb5cd6a4298c88e26481b76258bf5b09a11cf4b0))
### Bug Fixes
* Deleting workspace delete all workspace related values in DB ([#2961](https://github.com/windmill-labs/windmill/issues/2961)) ([c05d119](https://github.com/windmill-labs/windmill/commit/c05d119ae5dfb6f1bae88fc9d53b48a707cd2f15))
## [1.233.0](https://github.com/windmill-labs/windmill/compare/v1.232.0...v1.233.0) (2024-01-03)
### Features
* **frontend:** add the view runs buttons for operators ([#2932](https://github.com/windmill-labs/windmill/issues/2932)) ([821d2d7](https://github.com/windmill-labs/windmill/commit/821d2d7ea15e828dbb3683c85025dbc09c7e8dc7))
* Job caching now works with S3 files ([#2934](https://github.com/windmill-labs/windmill/issues/2934)) ([b44618f](https://github.com/windmill-labs/windmill/commit/b44618f35b6fe4558a5cd9fddec1e82216a2cf5e))
* PIP_LOCAL_DEPENDENCIES now accepts regex and is configurable from the UI ([#2940](https://github.com/windmill-labs/windmill/issues/2940)) ([e0140f1](https://github.com/windmill-labs/windmill/commit/e0140f175d371e239f315613a537820b9a6ad25f))
### Bug Fixes
* allow extraConfig for agGrid to set onGridReady ([74fc2d3](https://github.com/windmill-labs/windmill/commit/74fc2d3f4397d976fec326893f8196008cb8b332))
* decision tree fix wording ([#2941](https://github.com/windmill-labs/windmill/issues/2941)) ([0299c65](https://github.com/windmill-labs/windmill/commit/0299c656c368876581dc228c76513f1171ffd682))
* decision tree nits ([#2936](https://github.com/windmill-labs/windmill/issues/2936)) ([787017f](https://github.com/windmill-labs/windmill/commit/787017f53c8227e1246b09e412e92316977937ae))
* **frontend:** handle case where large json has a file and a filename ([#2951](https://github.com/windmill-labs/windmill/issues/2951)) ([2494171](https://github.com/windmill-labs/windmill/commit/2494171b9cb3d1857235b6558d276451d5550560))
* persist state for agGrid ([c085f5f](https://github.com/windmill-labs/windmill/commit/c085f5fa7ec598d0abf6aad98de0e5c7b1c92985))
* Tag all docker images with latest and main tags ([#2953](https://github.com/windmill-labs/windmill/issues/2953)) ([9655718](https://github.com/windmill-labs/windmill/commit/965571853ca253b130bcc4473190b6c9c98f44e1))
## [1.232.0](https://github.com/windmill-labs/windmill/compare/v1.231.0...v1.232.0) (2023-12-27)
### Features
* add summary to schedules ([606b03d](https://github.com/windmill-labs/windmill/commit/606b03d7613cf15ab9a60ef34a82657ff49ccdbd))
* no overlap for flows togglable on schedules ([4935528](https://github.com/windmill-labs/windmill/commit/49355280f6442c342a46f90b2fe2cb089b3f4eff))
### Bug Fixes
* fix file input ([263f41c](https://github.com/windmill-labs/windmill/commit/263f41cda3dba24bcaa2bbabfc3f5bc54c70f9cb))
* improve ata for bun ([b66fcbf](https://github.com/windmill-labs/windmill/commit/b66fcbf6261f69a57e6711fa8b9d1d499c1b47ed))
* use bun install in every case ([948b0da](https://github.com/windmill-labs/windmill/commit/948b0da59e6bcd3b6312373a1bbe52d541e54d56))
## [1.231.0](https://github.com/windmill-labs/windmill/compare/v1.230.0...v1.231.0) (2023-12-26)

View File

@@ -2,7 +2,7 @@
<a href="https://www.windmill.dev/"><img src="./imgs/windmill-banner.png" alt="windmill.dev"></a>
</p>
<p align=center>
Open-source developer infrastructure for internal tools (APIs, background jobs, workflows and UIs). Self-hostable alternative to Airplane, Pipedream, Superblocks and a simplified Temporal with autogenerated UIsm and custom UIs to trigger workflows and scripts as internal apps.
Open-source developer infrastructure for internal tools (APIs, background jobs, workflows and UIs). Self-hostable alternative to Airplane, Pipedream, Superblocks and a simplified Temporal with autogenerated UIs and custom UIs to trigger workflows and scripts as internal apps.
<p align=center>
Scripts are turned into sharable UIs automatically, and can be composed together into flows or used into richer apps built with low-code. Supported script languages supported are: Python, TypeScript, Go, Bash, SQL, and GraphQL.

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM account WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "08c943c4b9b76da43b8b754e5c66ab3b6397e7edc82666662a8ea466496290fb"
}

View File

@@ -32,7 +32,6 @@
"noop",
"appdependencies",
"deploymentcallback",
"scheduledscriptwithretry",
"singlescriptflow"
]
}
@@ -49,6 +48,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -47,7 +47,6 @@
"noop",
"appdependencies",
"deploymentcallback",
"scheduledscriptwithretry",
"singlescriptflow"
]
}
@@ -70,6 +69,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "git_sync",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "266741894b9a6ed650cf999b6e5b7c797424f8291517d3abf096fe45548a0273"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_stats SET timestamps = timestamps || $4, timeseries_float = timeseries_float || $5 WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"TimestamptzArray",
"Float4Array"
]
},
"nullable": []
},
"hash": "2ce7a562b1de63f767758642a4864ed982b74b53a143901480058f957b9641dc"
}

View File

@@ -42,6 +42,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM dependency_map\n WHERE importer_path = $1 AND importer_kind = 'script'\n AND workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "30e9250edd0c4c6025322f51b758174435d6df4f835cb0883ba40897cb99a410"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT schedule.*, t.jobs FROM schedule, LATERAL ( SELECT ARRAY (SELECT json_build_object('id', id, 'success', success, 'duration_ms', duration_ms) FROM completed_job WHERE\n completed_job.schedule_path = schedule.path AND completed_job.workspace_id = $1 AND parent_job IS NULL ORDER BY started_at DESC LIMIT 20) AS jobs ) t\n WHERE schedule.workspace_id = $1 ORDER BY schedule.edited_at desc LIMIT $2 OFFSET $3",
"query": "SELECT schedule.*, t.jobs FROM schedule, LATERAL ( SELECT ARRAY (SELECT json_build_object('id', id, 'success', success, 'duration_ms', duration_ms) FROM completed_job WHERE\n completed_job.schedule_path = schedule.path AND completed_job.workspace_id = $1 AND parent_job IS NULL AND is_skipped = False ORDER BY started_at DESC LIMIT 20) AS jobs ) t\n WHERE schedule.workspace_id = $1 ORDER BY schedule.edited_at desc LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
@@ -115,6 +115,16 @@
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
},
{
"ordinal": 24,
"name": "jobs",
"type_info": "JsonArray"
}
@@ -149,8 +159,10 @@
true,
false,
true,
true,
false,
null
]
},
"hash": "7a00843921db4ac1eb20a1045e83363587c1a2cb62d2df25d5564ae78a03b097"
"hash": "4422b7183ede17a9cbde4afae41be4da5447020e39398deedbcca9121492834a"
}

View File

@@ -28,6 +28,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "4c5276bbf6aaad004a58aaff6b2abd7e78903a0354dd041fcc0cc7cca2b60cc1"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path)\n VALUES ($1, $2, 'script', $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "5fbd5dfb8df9aecce023c5d482ee58efcbc23c8682a3f0ec94977eb56023ed72"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM input WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "60cd5ea09b2eba9df97ab011dd8575940a9dd5e244f3e877d3802fc88087234b"
}

View File

@@ -42,7 +42,6 @@
"noop",
"appdependencies",
"deploymentcallback",
"scheduledscriptwithretry",
"singlescriptflow"
]
}
@@ -63,6 +62,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT true FROM job_stats WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "661f472ff3860983322162420457f5033b9c9afc344d9c3e385ba20a3ad2197a"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_stats SET scalar_int = $4 WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "699427e7e3e6cd3ba4f61f77e171c5c27234cfa30460696ac619760667d7553c"
}

View File

@@ -37,6 +37,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO schedule (workspace_id, path, schedule, timezone, edited_by, script_path, is_flow, args, enabled, email, on_failure, on_failure_times, on_failure_exact, on_failure_extra_args, on_recovery, on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 ) RETURNING *",
"query": "INSERT INTO schedule (workspace_id, path, schedule, timezone, edited_by, script_path, is_flow, args, enabled, email, on_failure, on_failure_times, on_failure_exact, on_failure_extra_args, on_recovery, on_recovery_times, on_recovery_extra_args, ws_error_handler_muted, retry, summary, no_flow_overlap ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 ) RETURNING *",
"describe": {
"columns": [
{
@@ -112,6 +112,16 @@
"ordinal": 21,
"name": "retry",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
@@ -134,7 +144,9 @@
"Int4",
"Json",
"Bool",
"Jsonb"
"Jsonb",
"Varchar",
"Bool"
]
},
"nullable": [
@@ -159,8 +171,10 @@
true,
true,
false,
true
true,
true,
false
]
},
"hash": "b53442ca97ed9d25990405281c82c31062de24cbd2f99d1c4039965d861aafa9"
"hash": "6bb9e6cab7034bf28ef2597445dc617b60c49b42568fdb975cf2d2dc8702167e"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_stats SET timestamps = timestamps || $4, timeseries_int = timeseries_int || $5 WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"TimestamptzArray",
"Int4Array"
]
},
"nullable": []
},
"hash": "73a3a742702c1c8237a3216523dffe487146b14512d4882179978efe280956aa"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_stats WHERE job_id IN (SELECT id FROM completed_job WHERE created_at <= now() - ($1::bigint::text || ' s')::interval AND started_at + ((duration_ms/1000 + $1::bigint) || ' s')::interval <= now())",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "74949628ea78a5fecfd415e6406636a9442edd5b7939f61894288103be0c03ef"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM dependency_map WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "76d73f2fe5ac75e5033fb93d60f2ef5ab2b0b73a80dcb6a250c17c3b2f15c6b4"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM deployment_metadata WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "813d3c65d4c4bc10f1ec16ed4ee0954f4b938f45751ac03fd45479e665db1d40"
}

View File

@@ -112,6 +112,16 @@
"ordinal": 21,
"name": "retry",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
@@ -143,7 +153,9 @@
true,
true,
false,
true
true,
true,
false
]
},
"hash": "911b1e1f2a5ba6d5159916e5598020e680c45043b0736ad0153ee261a151dd90"

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM raw_app WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "96a77e839d6210629234879eeeaa23d9bddf5f751fe27d999659f6597f9759f1"
}

View File

@@ -112,6 +112,16 @@
"ordinal": 21,
"name": "retry",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
@@ -144,7 +154,9 @@
true,
true,
false,
true
true,
true,
false
]
},
"hash": "96dc1cd308f3c21cfb50b88048054dc03f93e261d25969d66aa48e9d0502960f"

View File

@@ -26,7 +26,6 @@
"noop",
"appdependencies",
"deploymentcallback",
"scheduledscriptwithretry",
"singlescriptflow"
]
}
@@ -43,6 +42,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -112,6 +112,16 @@
"ordinal": 21,
"name": "retry",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
@@ -142,7 +152,9 @@
true,
true,
false,
true
true,
true,
false
]
},
"hash": "aa2800113a8a8805f47cdc1dd0f29d94c546fe531e7edd3e91da4978af5442fb"

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT importer_path FROM dependency_map\n WHERE imported_path = $1 AND importer_kind = 'script'\n AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "importer_path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "aa44311f9bae8253c6c483d122597ce7e470c6ba1905e7e877dca35d9d68968f"
}

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM queue WHERE schedule_path = $1 AND workspace_id = $2 AND id != $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "aeedd3d92f7c50fe49d14053afebd82837093636da2950915ce4364106f5679c"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_stats SET scalar_float = $4 WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"Float4"
]
},
"nullable": []
},
"hash": "b56111c5b81de2449a1b687656a4636457da851a4db5e916cf4f6847c48c6bd3"
}

View File

@@ -42,6 +42,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE schedule SET schedule = $1, timezone = $2, args = $3, on_failure = $4, on_failure_times = $5, on_failure_exact = $6, on_failure_extra_args = $7, on_recovery = $8, on_recovery_times = $9, on_recovery_extra_args = $10, ws_error_handler_muted = $11, retry = $12\n WHERE path = $13 AND workspace_id = $14 RETURNING *",
"query": "UPDATE schedule SET schedule = $1, timezone = $2, args = $3, on_failure = $4, on_failure_times = $5, on_failure_exact = $6, on_failure_extra_args = $7, on_recovery = $8, on_recovery_times = $9, on_recovery_extra_args = $10, ws_error_handler_muted = $11, retry = $12, summary = $13, no_flow_overlap = $14 WHERE path = $15 AND workspace_id = $16 RETURNING *",
"describe": {
"columns": [
{
@@ -112,6 +112,16 @@
"ordinal": 21,
"name": "retry",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
@@ -128,6 +138,8 @@
"Json",
"Bool",
"Jsonb",
"Varchar",
"Bool",
"Text",
"Text"
]
@@ -154,8 +166,10 @@
true,
true,
false,
true
true,
true,
false
]
},
"hash": "e1673b211da54039c5cffa5a38fe23073149bc5bfcea1daccb657d5fa6892183"
"hash": "b7ed211ec7611c994a49565e46e5893788bcf49b2c9357fbc45a8353a7c84f48"
}

View File

@@ -112,6 +112,16 @@
"ordinal": 21,
"name": "retry",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
@@ -143,7 +153,9 @@
true,
true,
false,
true
true,
true,
false
]
},
"hash": "c10348d26e3e3dc9e345d9044db35db9906617eb9c98aaf58a55d9681ffb85e3"

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM job_stats WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "d77223fc94257711512b6ff21ce109b97324f2917113f04492f53b20860e9588"
}

View File

@@ -112,6 +112,16 @@
"ordinal": 21,
"name": "retry",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
@@ -143,7 +153,9 @@
true,
true,
false,
true
true,
true,
false
]
},
"hash": "dd74fa9468b5fe8c8ad657ded06076c11a78d0206af2af20685c691d4d9520bb"

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT content FROM script WHERE hash = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "content",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "deb2e4c21d4f9d0b9439780ca6ebee13450609b578c469cb437c5420ab520258"
}

View File

@@ -112,6 +112,16 @@
"ordinal": 21,
"name": "retry",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
@@ -143,7 +153,9 @@
true,
true,
false,
true
true,
true,
false
]
},
"hash": "eced0a09ba547ce1dccb54a5419b22373603c9d01f77047b3553bde125bf71e8"

View File

@@ -42,6 +42,7 @@
"bash",
"postgresql",
"nativets",
"Nativets",
"bun",
"mysql",
"bigquery",

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT no_flow_overlap FROM schedule WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "f2173822c1b3d82afedf0f7dba31e218539f0b259eb5f2c98e4e8ada6665f5ee"
}

View File

@@ -41,7 +41,6 @@
"noop",
"appdependencies",
"deploymentcallback",
"scheduledscriptwithretry",
"singlescriptflow"
]
}
@@ -66,7 +65,6 @@
"noop",
"appdependencies",
"deploymentcallback",
"scheduledscriptwithretry",
"singlescriptflow"
]
}

View File

@@ -112,6 +112,16 @@
"ordinal": 21,
"name": "retry",
"type_info": "Jsonb"
},
{
"ordinal": 22,
"name": "summary",
"type_info": "Varchar"
},
{
"ordinal": 23,
"name": "no_flow_overlap",
"type_info": "Bool"
}
],
"parameters": {
@@ -141,7 +151,9 @@
true,
true,
false,
true
true,
true,
false
]
},
"hash": "fdd3710a381dac33ef5ee5ec5564a4874e6d7807d43fa6ea7b8408cad9e97480"

700
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.231.0"
version = "1.236.2"
authors.workspace = true
edition.workspace = true
@@ -11,6 +11,7 @@ members = [
"./windmill-worker",
"./windmill-common",
"./windmill-audit",
"./windmill-git-sync",
"./parsers/windmill-parser",
"./parsers/windmill-parser-ts",
"./parsers/windmill-parser-wasm",
@@ -21,7 +22,7 @@ members = [
]
[workspace.package]
version = "1.231.0"
version = "1.236.2"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -34,7 +35,7 @@ opt-level = 0
incremental = true
[features]
enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise"]
enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-git-sync/enterprise"]
enterprise_saml = ["windmill-api/enterprise_saml"]
benchmark = ["windmill-api/benchmark", "windmill-worker/benchmark", "windmill-queue/benchmark"]
flamegraph = ["windmill-common/flamegraph", "windmill-worker/flamegraph"]
@@ -46,6 +47,7 @@ anyhow.workspace = true
tokio.workspace = true
dotenv.workspace = true
windmill-common = { workspace = true, features = ["tracing_init"] }
windmill-git-sync.workspace = true
windmill-api.workspace = true
windmill-worker.workspace = true
futures.workspace = true
@@ -66,7 +68,7 @@ uuid.workspace = true
gethostname.workspace = true
serde_json.workspace = true
serde.workspace = true
pg-embed = {git = "https://github.com/faokunega/pg-embed", optional = true}
pg-embed = {git = "https://github.com/faokunega/pg-embed", optional = true, default-features = false}
[dev-dependencies]
serde_json.workspace = true
@@ -83,6 +85,7 @@ windmill-queue = { path = "./windmill-queue" }
windmill-worker = { path = "./windmill-worker" }
windmill-common = { path = "./windmill-common" }
windmill-audit = { path = "./windmill-audit" }
windmill-git-sync = { path = "./windmill-git-sync" }
windmill-parser = { path = "./parsers/windmill-parser" }
windmill-parser-ts = { path = "./parsers/windmill-parser-ts" }
windmill-parser-py = { path = "./parsers/windmill-parser-py" }
@@ -153,7 +156,7 @@ swc_ecma_visit = "0.93.7"
base64 = "0.21.0"
hmac = "0.12.1"
sha2 = "0.10.6"
sqlx = { version = "^0", features = [
sqlx = { version = "0.7.3", features = [
"macros",
"migrate",
"uuid",
@@ -208,4 +211,7 @@ candle-transformers = "0.3.0"
candle-nn = "0.3.0"
tiberius = { version = "0.12.2", default-features = false, features = ["rustls", "tds73", "chrono"] }
aws-sdk-s3 = "1.5.0"
aws-config = "1.0.3"
aws-config = "1.0.3"
polars = { version = "0.35.2", features = ["lazy", "parquet", "aws", "csv", "dtype-full"] }
polars-io = { version = "0.35.2", features = ["csv"] }
object_store = { version = "0.8.0", features = ["aws"] }

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,3 @@
-- Add up migration script here
ALTER TABLE schedule ADD COLUMN summary VARCHAR(512);
ALTER TABLE schedule ADD COLUMN no_flow_overlap BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,2 @@
-- Add down migration script here
DROP TABLE dependency_map;

View File

@@ -0,0 +1,14 @@
-- Add up migration script here
CREATE TYPE IMPORTER_KIND AS ENUM ('script', 'flow', 'app');
CREATE TABLE IF NOT EXISTS dependency_map (
workspace_id VARCHAR(50) NOT NULL,
importer_path VARCHAR(510) NOT NULL,
importer_kind IMPORTER_KIND NOT NULL,
imported_path VARCHAR(510) NOT NULL,
PRIMARY KEY (workspace_id, importer_path, importer_kind, imported_path)
);
CREATE UNIQUE INDEX IF NOT EXISTS dependency_map_imported_path_idx ON dependency_map (workspace_id, imported_path);

View File

@@ -0,0 +1,3 @@
-- Add down migration script here
DROP TABLE job_stats;
DROP TYPE METRIC_KIND;

View File

@@ -0,0 +1,16 @@
-- Add up migration script here
CREATE TYPE METRIC_KIND AS ENUM ('scalar_int', 'scalar_float', 'timeseries_int', 'timeseries_float');
CREATE TABLE IF NOT EXISTS job_stats (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
job_id UUID NOT NULL,
metric_id VARCHAR(50) NOT NULL,
metric_name VARCHAR(255),
metric_kind METRIC_KIND NOT NULL,
scalar_int INTEGER,
scalar_float REAL,
timestamps TIMESTAMP WITH TIME ZONE[],
timeseries_int INTEGER[],
timeseries_float REAL[],
PRIMARY KEY (workspace_id, job_id, metric_id)
);

View File

@@ -63,7 +63,7 @@ lazy_static! {
fn process_import(module: Option<String>, path: &str, level: usize) -> Vec<String> {
if level > 0 {
let mut imports = vec!["requests".to_string()];
let mut imports = vec![];
let splitted_path = path.split("/");
let base = splitted_path
.clone()
@@ -78,10 +78,7 @@ fn process_import(module: Option<String>, path: &str, level: usize) -> Vec<Strin
} else if let Some(module) = module {
let imprt = module.split('.').next().unwrap_or("").replace("_", "-");
if imprt == "u" || imprt == "f" {
vec![
"requests".to_string(),
format!("relative:{}", module.replace(".", "/")),
]
vec![format!("relative:{}", module.replace(".", "/"))]
} else {
vec![imprt]
}
@@ -90,6 +87,57 @@ fn process_import(module: Option<String>, path: &str, level: usize) -> Vec<Strin
}
}
pub fn parse_relative_imports(code: &str, path: &str) -> error::Result<Vec<String>> {
let nimports = parse_code_for_imports(code, path)?;
return Ok(nimports
.into_iter()
.filter_map(|x| {
if x.starts_with("relative:") {
Some(x.replace("relative:", ""))
} else {
None
}
})
.collect());
}
fn parse_code_for_imports(code: &str, path: &str) -> error::Result<Vec<String>> {
let code = code.split(DEF_MAIN).next().unwrap_or("");
let ast = Suite::parse(code, "main.py").map_err(|e| {
error::Error::ExecutionErr(format!("Error parsing code: {}", e.to_string()))
})?;
let nimports: Vec<String> = ast
.into_iter()
.filter_map(|x| match x {
Stmt::Import(StmtImport { names, .. }) => Some(
names
.into_iter()
.map(|x| {
let name = x.name.to_string();
process_import(Some(name), path, 0)
})
.flatten()
.collect::<Vec<String>>(),
),
Stmt::ImportFrom(StmtImportFrom { level: Some(i), module, .. }) if i.to_u32() > 0 => {
Some(process_import(
module.map(|x| x.to_string()),
path,
i.to_usize(),
))
}
Stmt::ImportFrom(StmtImportFrom { level: _, module, .. }) => {
Some(process_import(module.map(|x| x.to_string()), path, 0))
}
_ => None,
})
.flatten()
.filter(|x| !STDIMPORTS.contains(&x.as_str()))
.unique()
.collect();
return Ok(nimports);
}
#[async_recursion]
pub async fn parse_python_imports(
code: &str,
@@ -127,41 +175,7 @@ pub async fn parse_python_imports(
imports.extend(lines);
}
let code = code.split(DEF_MAIN).next().unwrap_or("");
let ast = Suite::parse(code, "main.py").map_err(|e| {
error::Error::ExecutionErr(format!("Error parsing code: {}", e.to_string()))
})?;
let nimports: Vec<String> = ast
.into_iter()
.filter_map(|x| match x {
Stmt::Import(StmtImport { names, .. }) => Some(
names
.into_iter()
.map(|x| {
let name = x.name.to_string();
process_import(Some(name), path, 0)
})
.flatten()
.collect::<Vec<String>>(),
),
Stmt::ImportFrom(StmtImportFrom { level: Some(i), module, .. })
if i.to_u32() > 0 =>
{
Some(process_import(
module.map(|x| x.to_string()),
path,
i.to_usize(),
))
}
Stmt::ImportFrom(StmtImportFrom { level: _, module, .. }) => {
Some(process_import(module.map(|x| x.to_string()), path, 0))
}
_ => None,
})
.flatten()
.filter(|x| !STDIMPORTS.contains(&x.as_str()))
.unique()
.collect();
let nimports = parse_code_for_imports(code, path)?;
for n in nimports.iter() {
let nested = if n.starts_with("relative:") {
let rpath = n.replace("relative:", "");

View File

@@ -20,7 +20,7 @@ def main():
";
let r = parse_python_imports(code, "test-workspace", "f/foo/bar", &db).await?;
// println!("{}", serde_json::to_string(&r)?);
assert_eq!(r, vec!["matplotlib", "requests", "wmill", "zanzibar"]);
assert_eq!(r, vec!["matplotlib", "wmill", "zanzibar"]);
Ok(())
}
@@ -73,7 +73,6 @@ def main():
"numpy",
"pandas",
"pandas2",
"requests"
]
);

View File

@@ -431,7 +431,7 @@ Windmill Community Edition {GIT_VERSION}
},
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
load_require_preexisting_user(&db).await;
}
},
EXPOSE_METRICS_SETTING | EXPOSE_DEBUG_METRICS_SETTING => {
if n.payload() != EXPOSE_DEBUG_METRICS_SETTING || worker_mode {
tracing::info!("Metrics setting changed, restarting");

View File

@@ -35,8 +35,8 @@ use windmill_common::{
BASE_URL, DB, METRICS_DEBUG_ENABLED, METRICS_ENABLED,
};
use windmill_worker::{
create_token_for_owner, handle_job_error, AuthedClient, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR,
NPM_CONFIG_REGISTRY, PIP_EXTRA_INDEX_URL, SCRIPT_TOKEN_EXPIRY,
create_token_for_owner, handle_job_error, AuthedClient, SendResult, JOB_DEFAULT_TIMEOUT,
KEEP_JOB_DIR, NPM_CONFIG_REGISTRY, PIP_EXTRA_INDEX_URL, SCRIPT_TOKEN_EXPIRY,
};
#[cfg(feature = "enterprise")]
@@ -167,6 +167,7 @@ pub async fn load_metrics_debug_enabled(db: &DB) -> error::Result<()> {
};
Ok(())
}
pub async fn load_keep_job_dir(db: &DB) {
let value = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = $1",
@@ -250,25 +251,52 @@ pub async fn delete_expired_items(db: &DB) -> () {
let job_retention_secs = *JOB_RETENTION_SECS.read().await;
if job_retention_secs > 0 {
let deleted_jobs = sqlx::query_scalar!(
"DELETE FROM completed_job WHERE created_at <= now() - ($1::bigint::text || ' s')::interval AND started_at + ((duration_ms/1000 + $1::bigint) || ' s')::interval <= now() RETURNING id",
job_retention_secs
)
.fetch_all(db)
.await;
match db.begin().await {
Ok(mut tx) => {
let r = sqlx::query!(
"DELETE FROM job_stats WHERE job_id IN (SELECT id FROM completed_job WHERE created_at <= now() - ($1::bigint::text || ' s')::interval AND started_at + ((duration_ms/1000 + $1::bigint) || ' s')::interval <= now())",
job_retention_secs
)
.fetch_all(&mut *tx)
.await;
match r {
Ok(_) => {
let deleted_jobs = sqlx::query_scalar!(
"DELETE FROM completed_job WHERE created_at <= now() - ($1::bigint::text || ' s')::interval AND started_at + ((duration_ms/1000 + $1::bigint) || ' s')::interval <= now() RETURNING id",
job_retention_secs
)
.fetch_all(&mut *tx)
.await;
match deleted_jobs {
Ok(deleted_jobs) => {
if deleted_jobs.len() > 0 {
tracing::info!(
"deleted {} jobs completed JOB_RETENTION_SECS {} ago: {:?}",
deleted_jobs.len(),
job_retention_secs,
deleted_jobs,
)
match deleted_jobs {
Ok(deleted_jobs) => {
if deleted_jobs.len() > 0 {
tracing::info!(
"deleted {} jobs completed JOB_RETENTION_SECS {} ago: {:?}",
deleted_jobs.len(),
job_retention_secs,
deleted_jobs,
)
}
}
Err(e) => {
tracing::error!("Error deleting expired jobs: {:?}", e)
}
}
}
Err(err) => {
tracing::error!("Error deleting expired job stats: {:?}", err)
}
}
match tx.commit().await {
Ok(_) => (),
Err(err) => tracing::error!("Error deleting expired jobs: {:?}", err),
}
}
Err(e) => tracing::error!("Error deleting jobs: {}", e.to_string()),
Err(err) => {
tracing::error!("Error deleting expired jobs: {:?}", err)
}
}
}
}
@@ -729,6 +757,7 @@ async fn handle_zombie_jobs<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
// since the job is unrecoverable, the same worker queue should never be sent anything
let (same_worker_tx_never_used, _same_worker_rx_never_used) = mpsc::channel::<Uuid>(1);
let (send_result_never_used, _send_result_rx_never_used) = mpsc::channel::<SendResult>(1);
let token = create_token_for_owner(
&db,
@@ -767,6 +796,7 @@ async fn handle_zombie_jobs<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
"",
rsmq.clone(),
worker_name,
send_result_never_used,
)
.await;
}

View File

@@ -1805,7 +1805,7 @@ def main():
}
#[sqlx::test(fixtures("base"))]
async fn test_empty_loop(db: Pool<Postgres>) {
async fn test_empty_loop_1(db: Pool<Postgres>) {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await;
let port = server.addr.port();
@@ -2871,6 +2871,9 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) {
schedule: format!("{} {} * * * *", then.second(), then.minute()).to_string(),
ws_error_handler_muted: None,
retry: None,
no_flow_overlap: None,
summary: None,
};
let _ = client.create_schedule("test-workspace", &schedule).await;
@@ -2932,6 +2935,8 @@ async fn test_script_schedule_handlers(db: Pool<Postgres>) {
schedule: format!("{} {} * * * *", then.second(), then.minute()).to_string(),
ws_error_handler_muted: None,
retry: None,
summary: None,
no_flow_overlap: None
},
)
.await
@@ -3009,6 +3014,8 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) {
schedule: format!("{} {} * * * *", then.second(), then.minute()).to_string(),
ws_error_handler_muted: None,
retry: None,
no_flow_overlap: None,
summary: None,
};
let _ = client.create_schedule("test-workspace", &schedule).await;
@@ -3071,6 +3078,8 @@ async fn test_flow_schedule_handlers(db: Pool<Postgres>) {
schedule: format!("{} {} * * * *", then.second(), then.minute()).to_string(),
ws_error_handler_muted: None,
retry: None,
summary: None,
no_flow_overlap: None
},
)
.await

View File

@@ -9,7 +9,7 @@ name = "windmill_api"
path = "src/lib.rs"
[features]
enterprise = ["windmill-queue/enterprise", "async-stripe", "windmill-audit/enterprise"]
enterprise = ["windmill-queue/enterprise", "async-stripe", "windmill-audit/enterprise", "windmill-git-sync/enterprise"]
enterprise_saml = ["samael"]
benchmark = []
@@ -27,6 +27,7 @@ windmill-common = { workspace = true, features = [
windmill-audit.workspace = true
windmill-parser.workspace = true
windmill-parser-py-imports.workspace = true
windmill-git-sync.workspace = true
tokio.workspace = true
anyhow.workspace = true
argon2.workspace = true
@@ -84,7 +85,6 @@ candle-core.workspace = true
candle-transformers.workspace = true
candle-nn.workspace = true
aws-sdk-s3.workspace = true
aws-config.workspace = true
polars = { version = "0.35.2", features = ["lazy", "parquet", "aws", "csv", "dtype-full"] }
polars-io = { version = "0.35.2", features = ["csv"] }
object_store = { version = "0.8.0", features = ["aws"] }
polars.workspace = true
polars-io.workspace = true
object_store.workspace = true

View File

@@ -1,6 +1,6 @@
openapi: 3.0.3
info:
version: 1.230.0
version: 1.233.0
title: Windmill API
contact:
name: Windmill Team
@@ -1531,6 +1531,8 @@ paths:
type: string
git_repo_resource_path:
type: string
use_individual_branch:
type: boolean
required: &ref_17
- script_path
- git_repo_resource_path
@@ -3352,32 +3354,32 @@ paths:
id:
type: string
value:
oneOf: &ref_196
oneOf: &ref_204
- type: object
properties: &ref_180
properties: &ref_188
input_transforms:
type: object
additionalProperties:
oneOf: &ref_26
- type: object
properties: &ref_176
properties: &ref_184
value: {}
type:
type: string
enum:
- javascript
required: &ref_177
required: &ref_185
- expr
- type
- type: object
properties: &ref_178
properties: &ref_186
expr:
type: string
type:
type: string
enum:
- javascript
required: &ref_179
required: &ref_187
- expr
- type
discriminator: &ref_27
@@ -3417,13 +3419,13 @@ paths:
type: number
concurrency_time_window_s:
type: number
required: &ref_181
required: &ref_189
- type
- content
- language
- input_transforms
- type: object
properties: &ref_182
properties: &ref_190
input_transforms:
type: object
additionalProperties:
@@ -3437,12 +3439,12 @@ paths:
type: string
enum:
- script
required: &ref_183
required: &ref_191
- type
- path
- input_transforms
- type: object
properties: &ref_184
properties: &ref_192
input_transforms:
type: object
additionalProperties:
@@ -3454,12 +3456,12 @@ paths:
type: string
enum:
- flow
required: &ref_185
required: &ref_193
- type
- path
- input_transforms
- type: object
properties: &ref_186
properties: &ref_194
modules:
type: array
items:
@@ -3481,13 +3483,13 @@ paths:
type: boolean
parallelism:
type: integer
required: &ref_187
required: &ref_195
- modules
- iterator
- skip_failures
- type
- type: object
properties: &ref_188
properties: &ref_196
branches:
type: array
items:
@@ -3518,12 +3520,12 @@ paths:
type: string
enum:
- branchone
required: &ref_189
required: &ref_197
- branches
- default
- type
- type: object
properties: &ref_190
properties: &ref_198
branches:
type: array
items:
@@ -3548,28 +3550,28 @@ paths:
- branchall
parallel:
type: boolean
required: &ref_191
required: &ref_199
- branches
- type
- type: object
properties: &ref_192
properties: &ref_200
type:
type: string
enum:
- identity
flow:
type: boolean
required: &ref_193
required: &ref_201
- type
- type: object
properties: &ref_194
properties: &ref_202
type:
type: string
enum:
- graphql
required: &ref_195
required: &ref_203
- type
discriminator: &ref_197
discriminator: &ref_205
propertyName: type
mapping:
rawscript: '#/components/schemas/RawScript'
@@ -8240,6 +8242,10 @@ paths:
retry:
type: object
properties: *ref_78
no_flow_overlap:
type: boolean
summary:
type: string
required: &ref_148
- path
- schedule
@@ -8305,6 +8311,10 @@ paths:
retry:
type: object
properties: *ref_78
no_flow_overlap:
type: boolean
summary:
type: string
required: &ref_150
- schedule
- timezone
@@ -8446,6 +8456,10 @@ paths:
retry:
type: object
properties: *ref_78
summary:
type: string
no_flow_overlap:
type: boolean
required: &ref_80
- path
- edited_by
@@ -10309,6 +10323,91 @@ paths:
- upload_id
- parts
- is_done
/w/{workspace}/job_metrics/get/{id}:
post:
summary: get job metrics
operationId: getJobMetrics
tags:
- metrics
parameters:
- name: workspace
in: path
required: true
schema: *ref_0
- name: id
in: path
required: true
schema: *ref_74
requestBody:
description: parameters for statistics retrieval
required: true
content:
application/json:
schema:
type: object
properties:
timeseries_max_datapoints:
type: integer
from_timestamp:
type: string
format: date-time
to_timestamp:
type: string
format: date-time
responses:
'200':
description: job details
content:
application/json:
schema:
type: object
properties:
metrics_metadata:
type: array
items:
type: object
properties: &ref_176
id:
type: string
name:
type: string
required: &ref_177
- id
scalar_metrics:
type: array
items:
type: object
properties: &ref_178
metric_id:
type: string
value:
type: number
required: &ref_179
- id
- value
timeseries_metrics:
type: array
items:
type: object
properties: &ref_180
metric_id:
type: string
values:
type: array
items:
type: object
properties: &ref_182
timestamp:
type: string
format: date-time
value:
type: number
required: &ref_183
- timestamp
- value
required: &ref_181
- id
- values
components:
securitySchemes:
bearerAuth:
@@ -10960,56 +11059,72 @@ components:
type: object
properties: *ref_96
required: *ref_97
StaticTransform:
MetricMetadata:
type: object
properties: *ref_176
required: *ref_177
JavascriptTransform:
ScalarMetric:
type: object
properties: *ref_178
required: *ref_179
TimeseriesMetric:
type: object
properties: *ref_180
required: *ref_181
MetricDataPoint:
type: object
properties: *ref_182
required: *ref_183
StaticTransform:
type: object
properties: *ref_184
required: *ref_185
JavascriptTransform:
type: object
properties: *ref_186
required: *ref_187
InputTransform:
oneOf: *ref_26
discriminator: *ref_27
RawScript:
type: object
properties: *ref_180
required: *ref_181
properties: *ref_188
required: *ref_189
PathScript:
type: object
properties: *ref_182
required: *ref_183
properties: *ref_190
required: *ref_191
PathFlow:
type: object
properties: *ref_184
required: *ref_185
properties: *ref_192
required: *ref_193
FlowModule:
type: object
properties: *ref_28
required: *ref_29
ForloopFlow:
type: object
properties: *ref_186
required: *ref_187
BranchOne:
type: object
properties: *ref_188
required: *ref_189
BranchAll:
type: object
properties: *ref_190
required: *ref_191
Identity:
type: object
properties: *ref_192
required: *ref_193
Graphql:
type: object
properties: *ref_194
required: *ref_195
BranchOne:
type: object
properties: *ref_196
required: *ref_197
BranchAll:
type: object
properties: *ref_198
required: *ref_199
Identity:
type: object
properties: *ref_200
required: *ref_201
Graphql:
type: object
properties: *ref_202
required: *ref_203
FlowModuleValue:
oneOf: *ref_196
discriminator: *ref_197
oneOf: *ref_204
discriminator: *ref_205
Retry:
type: object
properties: *ref_78

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.231.0
version: 1.236.2
title: Windmill API
contact:
@@ -6674,14 +6674,17 @@ paths:
- storage_options
/w/{workspace}/job_helpers/v2/s3_resource_info:
post:
summary: Returns the s3 resource associated to the provided path, or the workspace default S3 resource
summary: Returns the s3 resource associated to the provided path, or the
workspace default S3 resource
operationId: s3ResourceInfo
tags:
- helpers
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: S3 resource path to use. If empty, the S3 resource defined in the workspace settings will be used
description:
S3 resource path to use. If empty, the S3 resource defined in the
workspace settings will be used
required: true
content:
application/json:
@@ -6917,6 +6920,53 @@ paths:
- parts
- is_done
/w/{workspace}/job_metrics/get/{id}:
post:
summary: get job metrics
operationId: getJobMetrics
tags:
- metrics
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/JobId"
requestBody:
description: parameters for statistics retrieval
required: true
content:
application/json:
schema:
type: object
properties:
timeseries_max_datapoints:
type: integer
from_timestamp:
type: string
format: date-time
to_timestamp:
type: string
format: date-time
responses:
"200":
description: job details
content:
application/json:
schema:
type: object
properties:
metrics_metadata:
type: array
items:
$ref: "#/components/schemas/MetricMetadata"
scalar_metrics:
type: array
items:
$ref: "#/components/schemas/ScalarMetric"
timeseries_metrics:
type: array
items:
$ref: "#/components/schemas/TimeseriesMetric"
components:
securitySchemes:
bearerAuth:
@@ -8399,6 +8449,10 @@ components:
type: boolean
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
summary:
type: string
no_flow_overlap:
type: boolean
required:
- path
- edited_by
@@ -8468,6 +8522,10 @@ components:
type: boolean
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
no_flow_overlap:
type: boolean
summary:
type: string
required:
- path
- schedule
@@ -8504,6 +8562,10 @@ components:
type: boolean
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
no_flow_overlap:
type: boolean
summary:
type: string
required:
- schedule
- timezone
@@ -9050,6 +9112,8 @@ components:
type: string
git_repo_resource_path:
type: string
use_individual_branch:
type: boolean
required:
- script_path
- git_repo_resource_path
@@ -9064,3 +9128,49 @@ components:
required:
- part_number
- tag
MetricMetadata:
type: object
properties:
id:
type: string
name:
type: string
required:
- id
ScalarMetric:
type: object
properties:
metric_id:
type: string
value:
type: number
required:
- id
- value
TimeseriesMetric:
type: object
properties:
metric_id:
type: string
values:
type: array
items:
$ref: "#/components/schemas/MetricDataPoint"
required:
- id
- values
MetricDataPoint:
type: object
properties:
timestamp:
type: string
format: date-time
value:
type: number
required:
- timestamp
- value

View File

@@ -9,7 +9,6 @@ use std::collections::HashMap;
*/
use crate::{
db::{ApiAuthed, DB},
deployment_metadata_helpers,
users::{require_owner_of_path, OptAuthed},
variables::build_crypt,
webhook_util::{WebhookMessage, WebhookShared},
@@ -588,12 +587,16 @@ async fn create_app(
)
.await?;
let mut tx = PushIsolationLevel::Transaction(tx);
let tx = PushIsolationLevel::Transaction(tx);
let (dependency_job_uuid, new_tx) = push(
&db,
tx,
&w_id,
JobPayload::AppDependencies { path: app.path.clone(), version: v_id },
JobPayload::AppDependencies {
path: app.path.clone(),
version: v_id,
deployment_message: app.deployment_message,
},
PushArgs::empty(),
&authed.username,
&authed.email,
@@ -615,25 +618,7 @@ async fn create_app(
.await?;
tracing::info!("Pushed app dependency job {}", dependency_job_uuid);
tx = PushIsolationLevel::Transaction(new_tx);
tx = deployment_metadata_helpers::handle_deployment_metadata(
tx,
&authed,
&db,
&w_id,
deployment_metadata_helpers::DeployedObject::App { path: app.path.clone(), version: v_id },
app.deployment_message,
)
.await?;
match tx {
PushIsolationLevel::Transaction(tx) => tx.commit().await?,
_ => {
return Err(Error::InternalErr(
"Expected a transaction here".to_string(),
));
}
}
new_tx.commit().await?;
webhook.send_message(
w_id.clone(),
@@ -849,14 +834,18 @@ async fn update_app(
)
.await?;
let mut tx: PushIsolationLevel<'_, rsmq_async::MultiplexedRsmq> =
let tx: PushIsolationLevel<'_, rsmq_async::MultiplexedRsmq> =
PushIsolationLevel::Transaction(tx);
if let Some(v_id) = v_id {
let (dependency_job_uuid, new_tx) = push(
&db,
tx,
&w_id,
JobPayload::AppDependencies { path: npath.clone(), version: v_id },
JobPayload::AppDependencies {
path: npath.clone(),
version: v_id,
deployment_message: ns.deployment_message,
},
PushArgs::empty(),
&authed.username,
&authed.email,
@@ -877,26 +866,7 @@ async fn update_app(
)
.await?;
tracing::info!("Pushed app dependency job {}", dependency_job_uuid);
tx = PushIsolationLevel::Transaction(new_tx);
tx = deployment_metadata_helpers::handle_deployment_metadata(
tx,
&authed,
&db,
&w_id,
deployment_metadata_helpers::DeployedObject::App { path: npath.clone(), version: v_id },
ns.deployment_message,
)
.await?;
}
match tx {
PushIsolationLevel::Transaction(tx) => tx.commit().await?,
_ => {
return Err(Error::InternalErr(
"Expected a transaction here".to_string(),
));
}
new_tx.commit().await?;
}
webhook.send_message(

View File

@@ -7,7 +7,6 @@
*/
use crate::db::ApiAuthed;
use crate::deployment_metadata_helpers;
use crate::{
db::DB,
schedule::clear_schedule,
@@ -355,7 +354,7 @@ async fn create_flow(
)
.await?;
let mut tx = PushIsolationLevel::Transaction(tx);
let tx = PushIsolationLevel::Transaction(tx);
let (dependency_job_uuid, mut new_tx) = push(
&db,
tx,
@@ -363,6 +362,7 @@ async fn create_flow(
JobPayload::FlowDependencies {
path: nf.path.clone(),
dedicated_worker: nf.dedicated_worker,
deployment_message: nf.deployment_message,
},
PushArgs::empty(),
&authed.username,
@@ -393,26 +393,7 @@ async fn create_flow(
.execute(&mut new_tx)
.await?;
tx = PushIsolationLevel::Transaction(new_tx);
tx = deployment_metadata_helpers::handle_deployment_metadata(
tx,
&authed,
&db,
&w_id,
deployment_metadata_helpers::DeployedObject::Flow { path: nf.path.clone() },
nf.deployment_message,
)
.await?;
match tx {
PushIsolationLevel::Transaction(tx) => tx.commit().await?,
_ => {
return Err(Error::InternalErr(
"Expected a transaction here".to_string(),
));
}
}
new_tx.commit().await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::CreateFlow { workspace: w_id.clone(), path: nf.path.clone() },
@@ -585,7 +566,7 @@ async fn update_flow(
},
);
let mut tx = PushIsolationLevel::Transaction(tx);
let tx = PushIsolationLevel::Transaction(tx);
let (dependency_job_uuid, mut new_tx) = push(
&db,
@@ -594,6 +575,7 @@ async fn update_flow(
JobPayload::FlowDependencies {
path: nf.path.clone(),
dedicated_worker: nf.dedicated_worker,
deployment_message: nf.deployment_message,
},
PushArgs::empty(),
&authed.username,
@@ -631,25 +613,7 @@ async fn update_flow(
.await?;
}
tx = PushIsolationLevel::Transaction(new_tx);
tx = deployment_metadata_helpers::handle_deployment_metadata(
tx,
&authed,
&db,
&w_id,
deployment_metadata_helpers::DeployedObject::Flow { path: nf.path.clone() },
nf.deployment_message,
)
.await?;
match tx {
PushIsolationLevel::Transaction(tx) => tx.commit().await?,
_ => {
return Err(Error::InternalErr(
"Expected a transaction here".to_string(),
));
}
}
new_tx.commit().await?;
Ok(nf.path.to_string())
}

View File

@@ -1,12 +1,8 @@
use std::{cmp, time::Duration};
use crate::{
db::DB, resources::get_resource_value_interpolated_internal, users::Tokened,
workspaces::LargeFileStorage,
};
use crate::{db::DB, resources::get_resource_value_interpolated_internal, users::Tokened};
use anyhow::Context;
use aws_sdk_s3::{
config::{BehaviorVersion, Credentials, Region},
presigning::PresigningConfig,
primitives::ByteStream,
types::{CompletedMultipartUpload, CompletedPart},
@@ -32,7 +28,11 @@ use polars::{
};
use serde::{Deserialize, Serialize};
use tower_http::cors::{Any, CorsLayer};
use windmill_common::{db::UserDB, error};
use windmill_common::{
db::UserDB,
error,
s3_helpers::{build_s3_client, render_endpoint, LargeFileStorage, S3Resource},
};
use crate::db::ApiAuthed;
@@ -87,23 +87,6 @@ pub fn workspaced_service() -> Router {
)
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct S3Resource {
#[serde(rename = "bucket")]
bucket: String,
region: String,
#[serde(rename = "endPoint")]
endpoint: String,
#[serde(rename = "useSSL")]
use_ssl: bool,
#[serde(rename = "accessKey")]
access_key: Option<String>,
#[serde(rename = "secretKey")]
secret_key: Option<String>,
#[serde(rename = "pathStyle")]
path_style: bool,
}
#[derive(Deserialize)]
struct DuckdbConnectionSettingsQuery {
s3_resource: S3Resource,
@@ -346,7 +329,10 @@ async fn test_connection(
.max_keys(1)
.send()
.await
.map_err(|err| error::Error::InternalErr(err.to_string()))?;
.map_err(|err| {
tracing::error!("Error testing connection to S3 bucket: {:?}", err);
error::Error::InternalErr(err.to_string())
})?;
return Ok(Json(()));
}
@@ -911,6 +897,11 @@ async fn multipart_upload_s3_file(
}));
}
#[derive(Deserialize)]
pub struct S3Object {
pub s3: String,
}
async fn get_workspace_s3_resource<'c>(
authed: &ApiAuthed,
user_db: &UserDB,
@@ -983,30 +974,6 @@ async fn get_s3_resource<'c>(
return Ok(Some(s3_resource));
}
fn build_s3_client(s3_resource_ref: &S3Resource) -> aws_sdk_s3::Client {
let s3_resource = s3_resource_ref.clone();
let endpoint = render_endpoint(&s3_resource);
let mut s3_config_builder = aws_sdk_s3::Config::builder()
.endpoint_url(endpoint)
.behavior_version(BehaviorVersion::latest())
.region(Region::new(s3_resource.region));
if s3_resource.access_key.is_some() {
s3_config_builder = s3_config_builder.credentials_provider(Credentials::new(
s3_resource.access_key.unwrap_or_default(),
s3_resource.secret_key.unwrap_or_default(),
None,
None,
"s3_storage",
));
}
if s3_resource.path_style {
s3_config_builder = s3_config_builder.force_path_style(true);
}
let s3_config = s3_config_builder.build();
return aws_sdk_s3::Client::from_conf(s3_config);
}
fn build_polars_s3_config(s3_resource_ref: &S3Resource) -> CloudOptions {
let s3_resource = s3_resource_ref.to_owned();
let mut s3_configs: Vec<(AmazonS3ConfigKey, String)> = vec![
@@ -1034,16 +1001,6 @@ fn build_polars_s3_config(s3_resource_ref: &S3Resource) -> CloudOptions {
return CloudOptions::default().with_aws(s3_configs);
}
fn render_endpoint(s3_resource: &S3Resource) -> String {
if s3_resource.endpoint.starts_with("http://") || s3_resource.endpoint.starts_with("https://") {
s3_resource.endpoint.clone()
} else if s3_resource.use_ssl {
format!("https://{}", s3_resource.endpoint)
} else {
format!("http://{}", s3_resource.endpoint)
}
}
async fn read_s3_text_object_head(
s3_client: &aws_sdk_s3::Client,
s3_bucket: &str,

View File

@@ -0,0 +1,165 @@
use crate::db::DB;
use axum::{extract::Path, routing::post, Extension, Json, Router};
use hyper::http;
use serde::{Deserialize, Serialize};
use tower_http::cors::{Any, CorsLayer};
use uuid::Uuid;
use windmill_common::{
error,
job_metrics::{JobStatsRecord, MetricKind},
};
pub fn workspaced_service() -> Router {
let cors = CorsLayer::new()
.allow_methods([http::Method::GET, http::Method::POST])
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
.allow_origin(Any);
Router::new().route("/get/:id", post(get_job_metrics).layer(cors.clone()))
}
#[derive(Deserialize)]
struct JobStatsRequest {
from_timestamp: Option<chrono::DateTime<chrono::Utc>>,
to_timestamp: Option<chrono::DateTime<chrono::Utc>>,
timeseries_max_datapoints: Option<u32>, // default to 100, any value lower than that will be ignored. Set to 0 to retrieve all
}
#[derive(Serialize)]
struct JobStatsResponse {
metrics_metadata: Vec<MetricsMetadata>,
scalar_metrics: Vec<ScalarMetric>,
timeseries_metrics: Vec<TimeseriesMetric>,
}
#[derive(Serialize)]
pub struct MetricsMetadata {
pub id: String,
pub name: Option<String>,
}
#[derive(Serialize)]
struct ScalarMetric {
metric_id: String,
value: f64,
}
#[derive(Serialize)]
struct TimeseriesMetric {
metric_id: String,
values: Vec<DataPoint>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DataPoint {
pub timestamp: chrono::DateTime<chrono::Utc>,
pub value: f64,
}
async fn get_job_metrics(
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Json(JobStatsRequest { from_timestamp, to_timestamp, timeseries_max_datapoints }): Json<
JobStatsRequest,
>,
) -> error::JsonResult<JobStatsResponse> {
let records = sqlx::query_as::<_, JobStatsRecord>(
"SELECT * FROM job_stats where workspace_id = $1 and job_id = $2",
)
.bind(w_id)
.bind(job_id)
.fetch_all(&db)
.await?;
let mut metrics_metadata: Vec<MetricsMetadata> = vec![];
let mut scalar_metrics: Vec<ScalarMetric> = vec![];
let mut timeseries_metrics: Vec<TimeseriesMetric> = vec![];
for record in records {
let metric_id = record.metric_id;
match record.metric_kind {
MetricKind::ScalarInt => {
let value = record.scalar_int.unwrap_or_default() as f64;
scalar_metrics.push(ScalarMetric { metric_id: metric_id.clone(), value });
}
MetricKind::ScalarFloat => {
let value = record.scalar_float.unwrap_or_default() as f64;
scalar_metrics.push(ScalarMetric { metric_id: metric_id.clone(), value });
}
MetricKind::TimeseriesInt => {
if record.timestamps.clone().unwrap_or_default().len()
!= record.timeseries_int.clone().unwrap_or_default().len()
{
tracing::warn!("Timeseries metric {} has an invalid shape. It doesn't have one timestamp per measurement. (timestamps: {:?}, measurements: {:?})", metric_id, record.timestamps, record.timeseries_int)
}
let (timestamps, timeseries_int) = timeseries_sample(
from_timestamp,
to_timestamp,
timeseries_max_datapoints,
record.timestamps.unwrap_or_default(),
record.timeseries_int.unwrap_or_default(),
);
let mut values: Vec<DataPoint> = vec![];
for (idx, value) in timeseries_int.iter().enumerate() {
values.push(DataPoint {
timestamp: timestamps[idx],
value: value.to_owned() as f64,
});
}
timeseries_metrics.push(TimeseriesMetric { metric_id: metric_id.clone(), values });
}
MetricKind::TimeseriesFloat => {
if record.timestamps.clone().unwrap_or_default().len()
!= record.timeseries_int.clone().unwrap_or_default().len()
{
tracing::warn!("Timeseries metric {} has an invalid shape. It doesn't have one timestamp per measurement. (timestamps: {:?}, measurements: {:?})", metric_id, record.timestamps, record.timeseries_float)
}
let (timestamps, timeseries_float) = timeseries_sample(
from_timestamp,
to_timestamp,
timeseries_max_datapoints,
record.timestamps.unwrap_or_default(),
record.timeseries_float.unwrap_or_default(),
);
let mut values: Vec<DataPoint> = vec![];
for (idx, value) in timeseries_float.iter().enumerate() {
values.push(DataPoint {
timestamp: timestamps[idx],
value: value.to_owned() as f64,
});
}
timeseries_metrics.push(TimeseriesMetric { metric_id: metric_id.clone(), values });
}
};
metrics_metadata.push(MetricsMetadata { id: metric_id, name: record.metric_name });
}
let response = JobStatsResponse { metrics_metadata, scalar_metrics, timeseries_metrics };
Ok(Json(response))
}
fn timeseries_sample<T: Copy>(
from: Option<chrono::DateTime<chrono::Utc>>,
to: Option<chrono::DateTime<chrono::Utc>>,
_datapoints: Option<u32>,
timestamps: Vec<chrono::DateTime<chrono::Utc>>,
values: Vec<T>,
) -> (Vec<chrono::DateTime<chrono::Utc>>, Vec<T>) {
if timestamps.len() != values.len() {
tracing::warn!("Timeseries metric has an invalid shape. It doesn't have one timestamp per measurement. (timestamps: {:?}, measurements: {:?})", timestamps.len(), values.len());
return (vec![], vec![]);
}
let mut filtered_timestamp: Vec<chrono::DateTime<chrono::Utc>> = vec![];
let mut filtered_values: Vec<T> = vec![];
for (idx, timestamp) in timestamps.iter().enumerate() {
if *timestamp > from.unwrap_or(chrono::DateTime::<chrono::Utc>::MIN_UTC)
&& *timestamp < to.unwrap_or(chrono::DateTime::<chrono::Utc>::MAX_UTC)
{
filtered_timestamp.push(timestamps[idx]);
filtered_values.push(values[idx]);
}
}
// TODO: implement sampling
return (filtered_timestamp, filtered_values);
}

View File

@@ -2826,14 +2826,14 @@ pub struct JobUpdate {
async fn get_job_update(
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery { running, log_offset }): Query<JobUpdateQuery>,
) -> error::JsonResult<JobUpdate> {
let record = sqlx::query!(
"SELECT running, substr(logs, $1) as logs, mem_peak FROM queue WHERE workspace_id = $2 AND id = $3",
log_offset,
&w_id,
&id
&job_id
)
.fetch_optional(&db)
.await?;
@@ -2855,11 +2855,11 @@ async fn get_job_update(
$3",
log_offset,
&w_id,
&id
&job_id
)
.fetch_optional(&db)
.await?;
let logs = not_found_if_none(logs, "Job Update", id.to_string())?;
let logs = not_found_if_none(logs, "Job Update", job_id.to_string())?;
Ok(Json(JobUpdate {
running: Some(false),
completed: Some(true),

View File

@@ -49,7 +49,6 @@ mod audit;
mod capture;
mod configs;
mod db;
mod deployment_metadata_helpers;
mod drafts;
pub mod ee;
pub mod embeddings;
@@ -61,6 +60,7 @@ mod groups;
mod inputs;
mod integration;
pub mod job_helpers;
pub mod job_metrics;
pub mod jobs;
pub mod oauth2;
mod openai;
@@ -198,6 +198,7 @@ pub async fn run_server(
.nest("/folders", folders::workspaced_service())
.nest("/groups", groups::workspaced_service())
.nest("/inputs", inputs::workspaced_service())
.nest("/job_metrics", job_metrics::workspaced_service())
.nest("/job_helpers", job_helpers::workspaced_service())
.nest("/jobs", jobs::workspaced_service())
.nest("/oauth", oauth2::workspaced_service())

View File

@@ -54,6 +54,8 @@ pub struct NewSchedule {
pub path: String,
pub schedule: String,
pub timezone: String,
pub summary: Option<String>,
pub no_flow_overlap: Option<bool>,
pub script_path: String,
pub is_flow: bool,
pub args: Option<serde_json::Value>,
@@ -153,9 +155,9 @@ async fn create_schedule(
"INSERT INTO schedule (workspace_id, path, schedule, timezone, edited_by, script_path, \
is_flow, args, enabled, email, on_failure, on_failure_times, on_failure_exact, \
on_failure_extra_args, on_recovery, on_recovery_times, on_recovery_extra_args, \
ws_error_handler_muted, retry \
ws_error_handler_muted, retry, summary, no_flow_overlap \
) VALUES ( \
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19 \
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 \
) RETURNING *",
w_id,
ns.path,
@@ -176,6 +178,8 @@ async fn create_schedule(
ns.on_recovery_extra_args,
ns.ws_error_handler_muted.unwrap_or(false),
ns.retry,
ns.summary,
ns.no_flow_overlap.unwrap_or(false),
)
.fetch_one(&mut tx)
.await
@@ -229,8 +233,8 @@ async fn edit_schedule(
Schedule,
"UPDATE schedule SET schedule = $1, timezone = $2, args = $3, on_failure = $4, on_failure_times = $5, \
on_failure_exact = $6, on_failure_extra_args = $7, on_recovery = $8, on_recovery_times = $9, \
on_recovery_extra_args = $10, ws_error_handler_muted = $11, retry = $12
WHERE path = $13 AND workspace_id = $14 RETURNING *",
on_recovery_extra_args = $10, ws_error_handler_muted = $11, retry = $12, summary = $13, no_flow_overlap = $14 \
WHERE path = $15 AND workspace_id = $16 RETURNING *",
es.schedule,
es.timezone,
es.args,
@@ -243,6 +247,8 @@ async fn edit_schedule(
es.on_recovery_extra_args,
es.ws_error_handler_muted.unwrap_or(false),
es.retry,
es.summary,
es.no_flow_overlap.unwrap_or(false),
path,
w_id,
)
@@ -336,6 +342,8 @@ pub struct ScheduleWJobs {
pub ws_error_handler_muted: bool,
pub retry: Option<serde_json::Value>,
pub jobs: Option<Vec<serde_json::Value>>,
pub summary: Option<String>,
pub no_flow_overlap: bool,
}
async fn list_schedule_with_jobs(
@@ -348,7 +356,7 @@ async fn list_schedule_with_jobs(
let (per_page, offset) = paginate(pagination);
let rows = sqlx::query_as!(ScheduleWJobs,
"SELECT schedule.*, t.jobs FROM schedule, LATERAL ( SELECT ARRAY (SELECT json_build_object('id', id, 'success', success, 'duration_ms', duration_ms) FROM completed_job WHERE
completed_job.schedule_path = schedule.path AND completed_job.workspace_id = $1 AND parent_job IS NULL ORDER BY started_at DESC LIMIT 20) AS jobs ) t
completed_job.schedule_path = schedule.path AND completed_job.workspace_id = $1 AND parent_job IS NULL AND is_skipped = False ORDER BY started_at DESC LIMIT 20) AS jobs ) t
WHERE schedule.workspace_id = $1 ORDER BY schedule.edited_at desc LIMIT $2 OFFSET $3",
w_id,
per_page as i64,
@@ -673,6 +681,7 @@ pub struct EditSchedule {
pub schedule: String,
pub timezone: String,
pub args: Option<serde_json::Value>,
pub summary: Option<String>,
pub on_failure: Option<String>,
pub on_failure_times: Option<i32>,
pub on_failure_exact: Option<bool>,
@@ -682,6 +691,7 @@ pub struct EditSchedule {
pub on_recovery_extra_args: Option<serde_json::Value>,
pub ws_error_handler_muted: Option<bool>,
pub retry: Option<serde_json::Value>,
pub no_flow_overlap: Option<bool>,
}
pub async fn clear_schedule<'c>(

View File

@@ -8,7 +8,6 @@
use crate::{
db::{ApiAuthed, DB},
deployment_metadata_helpers,
schedule::clear_schedule,
users::{maybe_refresh_folders, require_owner_of_path, AuthCache},
webhook_util::{WebhookMessage, WebhookShared},
@@ -47,6 +46,7 @@ use windmill_common::{
not_found_if_none, paginate, query_elems_from_hub, require_admin, Pagination, StripPath,
},
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_queue::{
self, schedule::push_scheduled_job, PushArgs, PushIsolationLevel, QueueTransaction,
};
@@ -325,7 +325,7 @@ async fn create_script(
let script_path = ns.path.clone();
let hash = ScriptHash(hash_script(&ns));
let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await;
let mut tx: QueueTransaction<'_, _> = (rsmq, user_db.begin(&authed).await?).into();
let mut tx: QueueTransaction<'_, _> = (rsmq.clone(), user_db.begin(&authed).await?).into();
if sqlx::query_scalar!(
"SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2",
@@ -604,37 +604,29 @@ async fn create_script(
);
}
let mut tx = PushIsolationLevel::Transaction(tx);
let permissioned_as = username_to_permissioned_as(&authed.username);
if needs_lock_gen {
let dependencies = match ns.language {
ScriptLang::Python3 => {
windmill_parser_py_imports::parse_python_imports(&ns.content, &w_id, &ns.path, &db)
.await?
.join("\n")
}
_ => ns.content,
};
let tag = if ns.dedicated_worker.is_some_and(|x| x) {
Some(format!("{}:{}", &w_id, &ns.path,))
} else {
ns.tag
};
let tx = PushIsolationLevel::Transaction(tx);
let (_, new_tx) = windmill_queue::push(
&db,
tx,
&w_id,
JobPayload::Dependencies {
hash,
dependencies,
language: ns.language,
path: ns.path,
dedicated_worker: ns.dedicated_worker,
deployment_message: ns.deployment_message,
},
PushArgs::empty(),
&authed.username,
&authed.email,
username_to_permissioned_as(&authed.username),
permissioned_as,
None,
None,
None,
@@ -650,26 +642,19 @@ async fn create_script(
None,
)
.await?;
tx = PushIsolationLevel::Transaction(new_tx);
}
tx = deployment_metadata_helpers::handle_deployment_metadata(
tx,
&authed,
&db,
&w_id,
deployment_metadata_helpers::DeployedObject::Script { hash: hash, path: script_path },
ns.deployment_message,
)
.await?;
match tx {
PushIsolationLevel::Transaction(tx) => tx.commit().await?,
_ => {
return Err(Error::InternalErr(
"Expected a transaction here".to_string(),
));
}
new_tx.commit().await?;
} else {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Script { hash: hash, path: script_path },
ns.deployment_message,
rsmq,
)
.await?;
tx.commit().await?;
}
Ok((StatusCode::CREATED, format!("{}", hash)))

View File

@@ -40,9 +40,11 @@ use stripe::CustomerId;
use uuid::Uuid;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::db::UserDB;
use windmill_common::s3_helpers::LargeFileStorage;
use windmill_common::schedule::Schedule;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::workspaces::WorkspaceGitRepo;
use windmill_common::{
error::{to_anyhow, Error, JsonResult, Result},
flows::Flow,
@@ -281,18 +283,6 @@ pub struct EditErrorHandler {
pub error_handler_muted_on_cancel: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
pub enum LargeFileStorage {
S3Storage(S3Storage),
// TODO: Add a filesystem type here in the future if needed
}
#[derive(Serialize, Deserialize, Debug)]
pub struct S3Storage {
pub s3_resource_path: String,
}
async fn list_pending_invites(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -1056,12 +1046,6 @@ struct EditGitSyncConfig {
git_sync_settings: Option<WorkspaceGitRepo>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct WorkspaceGitRepo {
pub script_path: String,
pub git_repo_resource_path: String,
}
async fn edit_git_sync_config(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1515,6 +1499,9 @@ async fn delete_workspace(
let mut tx = db.begin().await?;
require_super_admin(&db, &email).await?;
sqlx::query!("DELETE FROM dependency_map WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM queue WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
@@ -1533,6 +1520,12 @@ async fn delete_workspace(
sqlx::query!("DELETE FROM app WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM raw_app WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM input WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM variable WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
@@ -1548,6 +1541,17 @@ async fn delete_workspace(
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM job_stats WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM deployment_metadata WHERE workspace_id = $1",
&w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM usr WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
@@ -1575,6 +1579,10 @@ async fn delete_workspace(
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM account WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM workspace_key WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
@@ -1586,6 +1594,10 @@ async fn delete_workspace(
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM token WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id)
.execute(&mut *tx)
.await?;

View File

@@ -48,4 +48,6 @@ itertools.workspace = true
regex.workspace = true
git-version.workspace = true
cron.workspace = true
tracing-loki = { version = "^0", optional = true }
tracing-loki = { version = "^0", optional = true }
aws-sdk-s3.workspace = true
aws-config.workspace = true

View File

@@ -0,0 +1,187 @@
use crate::{db::DB, error};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(sqlx::FromRow)]
pub struct JobStatsRecord {
pub workspace_id: String,
pub job_id: Uuid,
pub metric_id: String,
pub metric_name: Option<String>,
pub metric_kind: MetricKind,
pub scalar_int: Option<i32>,
pub scalar_float: Option<f32>,
pub timestamps: Option<Vec<chrono::DateTime<chrono::Utc>>>,
pub timeseries_int: Option<Vec<i32>>,
pub timeseries_float: Option<Vec<f32>>,
}
#[derive(sqlx::Type, Debug, PartialEq, Deserialize, Serialize)]
#[sqlx(type_name = "METRIC_KIND", rename_all = "snake_case")]
pub enum MetricKind {
ScalarInt,
ScalarFloat,
TimeseriesInt,
TimeseriesFloat,
}
pub enum MetricNumericValue {
Integer(i32),
Float(f32),
}
pub async fn register_metric_for_job(
db: &DB,
workspace_id: String,
job_id: Uuid,
metric_id: String,
metric_kind: MetricKind,
metric_name: Option<String>,
) -> error::Result<String> {
let exists = sqlx::query_scalar!(
"SELECT true FROM job_stats WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
workspace_id,
job_id,
metric_id
)
.fetch_optional(db)
.await?
.flatten();
if exists.unwrap_or(false) {
return Err(error::Error::BadRequest(format!(
"Metric {} is already registered for job {}",
metric_id, job_id
)));
}
let (scalar_int, scalar_float, timestamps, timeseries_int, timeseries_float) = match metric_kind
{
MetricKind::ScalarInt | MetricKind::ScalarFloat => {
(None as Option<i32>, None as Option<f32>, None, None, None)
}
MetricKind::TimeseriesInt => (
None,
None,
Some(&[] as &[chrono::DateTime<chrono::Utc>]),
Some(&[] as &[i32]),
None,
),
MetricKind::TimeseriesFloat => (
None,
None,
Some(&[] as &[chrono::DateTime<chrono::Utc>]),
None,
Some(&[] as &[f32]),
),
};
sqlx::query(
"INSERT INTO job_stats (workspace_id, job_id, metric_id, metric_name, metric_kind, scalar_int, scalar_float, timestamps, timeseries_int, timeseries_float) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
)
.bind(workspace_id)
.bind(job_id)
.bind(&metric_id)
.bind(metric_name)
.bind(metric_kind)
.bind(scalar_int)
.bind(scalar_float)
.bind(timestamps)
.bind(timeseries_int)
.bind(timeseries_float)
.execute(db)
.await?;
Ok(metric_id)
}
pub async fn record_metric(
db: &DB,
workspace_id: String,
job_id: Uuid,
metric_id: String,
value: MetricNumericValue,
) -> error::Result<()> {
let metric_kind_opt: Option<MetricKind> = sqlx::query_scalar(
"SELECT metric_kind FROM job_stats WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
)
.bind(&workspace_id)
.bind(&job_id)
.bind(&metric_id)
.fetch_optional(db)
.await?;
if metric_kind_opt.is_none() {
return Err(error::Error::BadRequest(format!(
"Metric {} not yet registered for job {}.",
metric_id, job_id
)));
}
let metric_kind = metric_kind_opt.unwrap();
let (value_int, value_float) = match value {
MetricNumericValue::Integer(val) => {
if metric_kind != MetricKind::TimeseriesInt && metric_kind != MetricKind::ScalarInt {
return Err(error::Error::BadRequest(format!(
"Metric {} is not a timeseries int metric.",
metric_id
)));
}
(val, 0 as f32)
}
MetricNumericValue::Float(val) => {
if metric_kind != MetricKind::TimeseriesFloat && metric_kind != MetricKind::ScalarFloat
{
return Err(error::Error::BadRequest(format!(
"Metric {} is not a timeseries float metric.",
metric_id
)));
}
(0 as i32, val)
}
};
let timestamp = chrono::Utc::now();
match metric_kind {
MetricKind::ScalarInt => {
sqlx::query!(
"UPDATE job_stats SET scalar_int = $4 WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
&workspace_id,
&job_id,
&metric_id,
value_int,
).execute(db).await?;
}
MetricKind::ScalarFloat => {
sqlx::query!(
"UPDATE job_stats SET scalar_float = $4 WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
&workspace_id,
&job_id,
&metric_id,
value_float,
).execute(db).await?;
}
MetricKind::TimeseriesInt => {
sqlx::query!(
"UPDATE job_stats SET timestamps = timestamps || $4, timeseries_int = timeseries_int || $5 WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
&workspace_id,
&job_id,
&metric_id,
&[timestamp],
&[value_int]
).execute(db).await?;
}
MetricKind::TimeseriesFloat => {
sqlx::query!(
"UPDATE job_stats SET timestamps = timestamps || $4, timeseries_float = timeseries_float || $5 WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
&workspace_id,
&job_id,
&metric_id,
&[timestamp],
&[value_float]
).execute(db).await?;
}
}
Ok(())
}

View File

@@ -283,17 +283,19 @@ pub enum JobPayload {
Dependencies {
path: String,
hash: ScriptHash,
dependencies: String,
language: ScriptLang,
dedicated_worker: Option<bool>,
deployment_message: Option<String>,
},
FlowDependencies {
path: String,
dedicated_worker: Option<bool>,
deployment_message: Option<String>,
},
AppDependencies {
path: String,
version: i64,
deployment_message: Option<String>,
},
Flow {
path: String,

View File

@@ -23,9 +23,11 @@ pub mod external_ip;
pub mod flow_status;
pub mod flows;
pub mod global_settings;
pub mod job_metrics;
pub mod jobs;
pub mod more_serde;
pub mod oauth2;
pub mod s3_helpers;
pub mod schedule;
pub mod scripts;
pub mod server;
@@ -34,6 +36,7 @@ pub mod users;
pub mod utils;
pub mod variables;
pub mod worker;
pub mod workspaces;
#[cfg(feature = "tracing_init")]
pub mod tracing_init;

View File

@@ -0,0 +1,96 @@
use aws_config::{BehaviorVersion, Region};
use aws_sdk_s3::config::Credentials;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
pub enum LargeFileStorage {
S3Storage(S3Storage),
// TODO: Add a filesystem type here in the future if needed
}
#[derive(Serialize, Deserialize, Debug)]
pub struct S3Storage {
pub s3_resource_path: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct S3Resource {
#[serde(rename = "bucket")]
pub bucket: String,
pub region: String,
#[serde(rename = "endPoint")]
pub endpoint: String,
#[serde(rename = "useSSL")]
pub use_ssl: bool,
#[serde(rename = "accessKey")]
pub access_key: Option<String>,
#[serde(rename = "secretKey")]
pub secret_key: Option<String>,
#[serde(rename = "pathStyle")]
pub path_style: bool,
pub port: Option<u16>,
}
#[derive(Deserialize, Clone)]
pub struct S3Object {
pub s3: String,
}
pub async fn get_etag_or_empty(s3_resource: &S3Resource, s3_object: S3Object) -> Option<String> {
let s3_client = build_s3_client(s3_resource);
let s3_bucket = s3_resource.bucket.clone();
let s3_key = s3_object.s3;
let s3_object_metadata = s3_client
.head_object()
.bucket(s3_bucket)
.key(s3_key)
.send()
.await;
s3_object_metadata
.ok()
.map(|res| res.e_tag().map(|et| et.to_string()))
.flatten()
}
pub fn render_endpoint(s3_resource: &S3Resource) -> String {
let url_with_prefix = if s3_resource.endpoint.starts_with("http://")
|| s3_resource.endpoint.starts_with("https://")
{
s3_resource.endpoint.clone()
} else if s3_resource.use_ssl {
format!("https://{}", s3_resource.endpoint)
} else {
format!("http://{}", s3_resource.endpoint)
};
if s3_resource.port.is_some() {
format!("{}:{}", url_with_prefix, s3_resource.port.unwrap())
} else {
url_with_prefix
}
}
pub fn build_s3_client(s3_resource_ref: &S3Resource) -> aws_sdk_s3::Client {
let s3_resource = s3_resource_ref.clone();
let endpoint = render_endpoint(&s3_resource);
let mut s3_config_builder = aws_sdk_s3::Config::builder()
.endpoint_url(endpoint)
.behavior_version(BehaviorVersion::latest())
.region(Region::new(s3_resource.region));
if s3_resource.access_key.is_some() {
s3_config_builder = s3_config_builder.credentials_provider(Credentials::new(
s3_resource.access_key.unwrap_or_default(),
s3_resource.secret_key.unwrap_or_default(),
None,
None,
"s3_storage",
));
}
if s3_resource.path_style {
s3_config_builder = s3_config_builder.force_path_style(true);
}
let s3_config = s3_config_builder.build();
return aws_sdk_s3::Client::from_conf(s3_config);
}

View File

@@ -37,6 +37,8 @@ pub struct Schedule {
pub on_recovery_extra_args: Option<serde_json::Value>,
pub ws_error_handler_muted: bool,
pub retry: Option<serde_json::Value>,
pub no_flow_overlap: bool,
pub summary: Option<String>,
}
impl Schedule {

View File

@@ -8,6 +8,7 @@
pub const SUPERADMIN_SECRET_EMAIL: &str = "superadmin_secret@windmill.dev";
pub const SUPERADMIN_NOTIFICATION_EMAIL: &str = "superadmin_notification@windmill.dev";
pub const SUPERADMIN_SYNC_EMAIL: &str = "superadmin_sync@windmill.dev";
pub fn username_to_permissioned_as(user: &str) -> String {
if user.contains('@') {

View File

@@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct WorkspaceGitRepo {
pub script_path: String,
pub git_repo_resource_path: String,
pub use_individual_branch: Option<bool>,
}

View File

@@ -0,0 +1,22 @@
[package]
name = "windmill-git-sync"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_git_sync"
path = "./src/lib.rs"
[features]
enterprise = ["windmill-queue/enterprise"]
default = []
[dependencies]
serde.workspace = true
sqlx.workspace = true
serde_json.workspace = true
tracing.workspace = true
windmill-common = { workspace = true, features = ["axum"] }
windmill-queue.workspace = true
rsmq_async.workspace = true

View File

@@ -8,17 +8,18 @@
use std::collections::HashMap;
use crate::db::ApiAuthed;
use crate::db::DB;
use crate::workspaces::{WorkspaceGitRepo, WorkspaceSettings};
use sqlx::{Pool, Postgres};
use windmill_common::users::SUPERADMIN_SYNC_EMAIL;
use windmill_common::workspaces::WorkspaceGitRepo;
use serde_json::json;
use windmill_common::error::{Error, Result};
use windmill_common::jobs::JobPayload;
use windmill_common::scripts::ScriptHash;
use windmill_common::users::username_to_permissioned_as;
use windmill_queue::PushIsolationLevel;
pub type DB = Pool<Postgres>;
#[derive(Clone)]
pub enum DeployedObject {
Script { hash: ScriptHash, path: String },
@@ -37,13 +38,14 @@ impl DeployedObject {
}
pub async fn handle_deployment_metadata<'c, R: rsmq_async::RsmqConnection + Send + 'c>(
mut tx: PushIsolationLevel<'c, R>,
authed: &ApiAuthed,
email: &str,
created_by: &str,
db: &DB,
w_id: &str,
obj: DeployedObject,
deployment_message: Option<String>,
) -> Result<PushIsolationLevel<'c, R>> {
rsmq: Option<R>,
) -> Result<()> {
let skip_git_sync = if obj.get_path().starts_with("u/") {
tracing::debug!(
"Ignoring {} from git sync as it's in a private user folder",
@@ -54,10 +56,10 @@ pub async fn handle_deployment_metadata<'c, R: rsmq_async::RsmqConnection + Send
false
};
let workspace_git_repo_setting = sqlx::query_as::<_, WorkspaceSettings>(
"SELECT * FROM workspace_settings WHERE workspace_id = $1",
let workspace_git_repo_setting = sqlx::query_scalar!(
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
w_id
)
.bind(&w_id)
.fetch_optional(db)
.await?;
@@ -69,11 +71,10 @@ pub async fn handle_deployment_metadata<'c, R: rsmq_async::RsmqConnection + Send
let workspace_git_repo = workspace_git_repo_setting
.unwrap()
.git_sync
.map(|conf| serde_json::from_value::<WorkspaceGitRepo>(conf).ok())
.flatten();
let (git_sync_job_uuid, mut new_tx) = if !skip_git_sync && workspace_git_repo.is_some() {
let git_sync_job_uuid = if !skip_git_sync && workspace_git_repo.is_some() {
let workspace_git_repo = workspace_git_repo.unwrap();
let mut args: HashMap<String, serde_json::Value> = HashMap::new();
@@ -84,35 +85,56 @@ pub async fn handle_deployment_metadata<'c, R: rsmq_async::RsmqConnection + Send
.strip_prefix("$res:")),
);
let default_commit_msg: String;
match obj.clone() {
let message = match obj.clone() {
DeployedObject::Script { path, .. } => {
args.insert("path".to_string(), json!(path.to_string()));
default_commit_msg = format!("Script '{}' deployed", path);
if deployment_message.as_ref().is_none()
|| deployment_message.as_ref().is_some_and(|x| x.is_empty())
{
format!("Script '{}' deployed", path)
} else {
deployment_message.clone().unwrap()
}
}
DeployedObject::Flow { path } => {
args.insert("path".to_string(), json!(path.to_string()));
default_commit_msg = format!("Flow '{}' deployed", path);
if deployment_message.as_ref().is_none()
|| deployment_message.as_ref().is_some_and(|x| x.is_empty())
{
format!("Flow '{}' deployed", path)
} else {
deployment_message.clone().unwrap()
}
}
DeployedObject::App { path, .. } => {
args.insert("path".to_string(), json!(path.to_string()));
default_commit_msg = format!("App '{}' deployed", path);
if deployment_message.as_ref().is_none()
|| deployment_message.as_ref().is_some_and(|x| x.is_empty())
{
format!("App '{}' deployed", path)
} else {
deployment_message.clone().unwrap()
}
}
}
};
args.insert("commit_msg".to_string(), json!(message));
args.insert(
"commit_msg".to_string(),
json!(deployment_message.clone().unwrap_or(default_commit_msg)),
"use_individual_branch".to_string(),
json!(workspace_git_repo.use_individual_branch.unwrap_or(false)),
);
let tx: PushIsolationLevel<'_, R> = PushIsolationLevel::IsolatedRoot(db.clone(), rsmq);
let (job_uuid, new_tx) = windmill_queue::push(
&db,
db,
tx,
&w_id,
JobPayload::DeploymentCallback { path: workspace_git_repo.script_path.clone() },
args,
&authed.username,
&authed.email,
username_to_permissioned_as(&authed.username),
created_by,
&email,
SUPERADMIN_SYNC_EMAIL.to_string(),
None,
None,
None,
@@ -128,16 +150,10 @@ pub async fn handle_deployment_metadata<'c, R: rsmq_async::RsmqConnection + Send
None,
)
.await?;
(Some(job_uuid), new_tx)
new_tx.commit().await?;
Some(job_uuid)
} else {
let new_tx = match tx {
PushIsolationLevel::Isolated(user_db, authed, rsmq) => {
(rsmq, user_db.begin(&authed).await?).into()
}
PushIsolationLevel::IsolatedRoot(db, rsmq) => (rsmq, db.begin().await?).into(),
PushIsolationLevel::Transaction(tx) => tx,
};
(None, new_tx)
None
};
// We're not persisting the default commit msg as it's pretty useless. We will persist the ones manually set by users
@@ -149,28 +165,27 @@ pub async fn handle_deployment_metadata<'c, R: rsmq_async::RsmqConnection + Send
if deployment_message.is_some() || job_uuids.len() > 0 {
// if the git sync job hasn't been triggered, and there is not custom deployment message, there's not point adding an entry to the table
match obj.clone() {
DeployedObject::Script { path, hash, .. } => {
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, script_hash, callback_job_ids, deployment_msg) VALUES ($1, $2, $3, $4, $5)",
w_id, path, hash.0, &job_uuids, deployment_message,
)
},
DeployedObject::Flow { path } => {
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, callback_job_ids, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, path) WHERE script_hash IS NULL AND app_version IS NULL DO UPDATE SET callback_job_ids = $3, deployment_msg = $4",
w_id, path, &job_uuids, deployment_message,
)
}
DeployedObject::App { path, version } => {
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, app_version, callback_job_ids, deployment_msg) VALUES ($1, $2, $3, $4, $5)",
w_id, path, version, &job_uuids, deployment_message,
)
}
}.execute(&mut new_tx)
.await?;
DeployedObject::Script { path, hash, .. } => {
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, script_hash, callback_job_ids, deployment_msg) VALUES ($1, $2, $3, $4, $5)",
w_id, path, hash.0, &job_uuids, deployment_message,
)
},
DeployedObject::Flow { path } => {
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, callback_job_ids, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, path) WHERE script_hash IS NULL AND app_version IS NULL DO UPDATE SET callback_job_ids = $3, deployment_msg = $4",
w_id, path, &job_uuids, deployment_message,
)
}
DeployedObject::App { path, version } => {
sqlx::query!(
"INSERT INTO deployment_metadata (workspace_id, path, app_version, callback_job_ids, deployment_msg) VALUES ($1, $2, $3, $4, $5)",
w_id, path, version, &job_uuids, deployment_message,
)
}
}.execute(db)
.await?;
}
tx = PushIsolationLevel::Transaction(new_tx);
return Ok(tx);
return Ok(());
}

View File

@@ -1003,6 +1003,8 @@ pub async fn handle_maybe_scheduled_job<'c, R: rsmq_async::RsmqConnection + Clon
on_recovery_extra_args: schedule.on_recovery_extra_args,
ws_error_handler_muted: schedule.ws_error_handler_muted,
retry: schedule.retry,
summary: schedule.summary,
no_flow_overlap: schedule.no_flow_overlap,
},
)
.await;
@@ -2494,21 +2496,23 @@ pub async fn push<'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection
dedicated_worker,
None,
),
JobPayload::Dependencies { hash, dependencies, language, path, dedicated_worker } => (
Some(hash.0),
Some(path),
Some((dependencies, None)),
JobKind::Dependencies,
None,
None,
Some(language),
None,
None,
None,
dedicated_worker,
None,
),
JobPayload::FlowDependencies { path, dedicated_worker } => {
JobPayload::Dependencies { hash, language, path, dedicated_worker, deployment_message } => {
(
Some(hash.0),
Some(path),
Some((deployment_message.unwrap_or_else(String::new), None)),
JobKind::Dependencies,
None,
None,
Some(language),
None,
None,
None,
dedicated_worker,
None,
)
}
JobPayload::FlowDependencies { path, dedicated_worker, deployment_message } => {
let value_json = fetch_scalar_isolated!(
sqlx::query_scalar!(
"SELECT value FROM flow WHERE path = $1 AND workspace_id = $2",
@@ -2526,7 +2530,7 @@ pub async fn push<'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection
(
None,
Some(path),
None,
Some((deployment_message.unwrap_or_else(String::new), None)),
JobKind::FlowDependencies,
Some(value.clone()),
Some(FlowStatus::new(&value)), // this is a new flow being pushed, flow_status is set to flow_value
@@ -2538,10 +2542,10 @@ pub async fn push<'c, T: Serialize + Send + Sync, R: rsmq_async::RsmqConnection
None,
)
}
JobPayload::AppDependencies { path, version } => (
JobPayload::AppDependencies { path, version, deployment_message } => (
Some(version),
Some(path),
None,
Some((deployment_message.unwrap_or_else(String::new), None)),
JobKind::AppDependencies,
None,
None,

View File

@@ -10,7 +10,7 @@ path = "src/lib.rs"
[features]
default = []
enterprise = ["windmill-queue/enterprise", "dep:gcp_auth", "dep:jsonwebtoken", "dep:pem", "dep:sha2", "dep:tiberius", "dep:tokio-util"]
enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "dep:gcp_auth", "dep:jsonwebtoken", "dep:pem", "dep:sha2", "dep:tiberius", "dep:tokio-util"]
benchmark = ["windmill-queue/benchmark"]
flamegraph = []
@@ -31,6 +31,7 @@ windmill-parser-py-imports.workspace = true
windmill-parser-bash.workspace = true
windmill-parser-sql.workspace = true
windmill-parser-graphql.workspace = true
windmill-git-sync.workspace = true
sqlx.workspace = true
uuid.workspace = true
tracing.workspace = true

View File

@@ -28,22 +28,23 @@ class WindmillFinder(MetaPathFinder):
return ModuleSpec(name, WindmillLoader(name))
elif l > 2:
script_path = "/".join(splitted)
import requests
import urllib.parse
import urllib.request
headers = {"Authorization": f"Bearer {os.environ.get('WM_TOKEN')}"}
url = f"{os.environ.get('BASE_INTERNAL_URL')}/api/w/{os.environ.get('WM_WORKSPACE')}/scripts/raw/p/{script_path}.py"
r = requests.get(
url, headers={"Authorization": f"Bearer {os.environ.get('WM_TOKEN')}"}
)
if r.status_code == 200:
folder = os.getcwd() + "/tmp/" + "/".join(splitted[:-1])
fullpath = folder + "/" + splitted[-1] + ".py"
os.makedirs(folder, exist_ok=True)
with open(fullpath, "w+") as f:
f.write(r.text)
return ModuleSpec(name, SourceFileLoader(name, fullpath))
else:
req = urllib.request.Request(url, None, headers)
try:
with urllib.request.urlopen(req) as response:
r = response.read().decode("utf-8")
folder = os.getcwd() + "/tmp/" + "/".join(splitted[:-1])
fullpath = folder + "/" + splitted[-1] + ".py"
os.makedirs(folder, exist_ok=True)
with open(fullpath, "w+") as f:
f.write(r)
return ModuleSpec(name, SourceFileLoader(name, fullpath))
except:
# raise ImportError(f"Script {script_path} not found")
return ModuleSpec(name, WindmillLoader(name))

View File

@@ -86,7 +86,7 @@ pub async fn gen_lockfile(
// if custom NPM registry is being used, write bunfig.toml at the root of the job dir
if let Some(ref s) = NPM_CONFIG_REGISTRY.read().await.clone() {
let (raw_url, token_opt) = if s.contains(":_authToken=") {
let (url, token_opt) = if s.contains(":_authToken=") {
let split_url = s.split(":_authToken=").collect::<Vec<&str>>();
let url = split_url
.get(0)
@@ -100,12 +100,6 @@ pub async fn gen_lockfile(
} else {
(s.to_owned(), None)
};
// somehow bun fails to resolve deps if the url does not end with a slash ...
let url = if !raw_url.ends_with("/") {
format!("{raw_url}/")
} else {
raw_url.to_string()
};
let registry_toml_string = if let Some(token) = token_opt {
format!("{{ url = \"{url}\", token = \"{token}\" }}")
} else {
@@ -342,26 +336,26 @@ pub async fn handle_bun_job(
let trusted_deps = get_trusted_deps(inner_content);
let empty_trusted_deps = trusted_deps.len() == 0;
let has_custom_config_registry = common_bun_proc_envs.contains_key("NPM_CONFIG_REGISTRY");
if !*DISABLE_NSJAIL || !empty_trusted_deps || has_custom_config_registry {
logs.push_str("\n\n--- BUN INSTALL ---\n");
set_logs(&logs, &job.id, &db).await;
let _ = gen_lockfile(
logs,
mem_peak,
canceled_by,
&job.id,
&job.workspace_id,
db,
&client.get_token().await,
&job.script_path(),
job_dir,
base_internal_url,
worker_name,
false,
trusted_deps,
)
.await?;
}
// if !*DISABLE_NSJAIL || !empty_trusted_deps || has_custom_config_registry {
logs.push_str("\n\n--- BUN INSTALL ---\n");
set_logs(&logs, &job.id, &db).await;
let _ = gen_lockfile(
logs,
mem_peak,
canceled_by,
&job.id,
&job.workspace_id,
db,
&client.get_token().await,
&job.script_path(),
job_dir,
base_internal_url,
worker_name,
false,
trusted_deps,
)
.await?;
// }
if empty_trusted_deps && !has_custom_config_registry {
let node_modules_path = format!("{}/node_modules", job_dir);

View File

@@ -3,12 +3,13 @@ use itertools::Itertools;
use nix::sys::signal::{self, Signal};
use nix::unistd::Pid;
use regex::Regex;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use tokio::process::Command;
use tokio::{fs::File, io::AsyncReadExt};
use windmill_common::s3_helpers::{get_etag_or_empty, LargeFileStorage, S3Object, S3Resource};
use windmill_common::worker::{CLOUD_HOSTED, WORKER_CONFIG};
use windmill_common::{
error::{self, Error},
@@ -31,7 +32,7 @@ use std::{
use tracing::{trace_span, Instrument};
use uuid::Uuid;
use windmill_common::variables;
use windmill_common::{job_metrics, variables, DB};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
@@ -407,6 +408,7 @@ async fn get_mem_peak(pid: Option<u32>, nsjail: bool) -> i32 {
}
-2
} else {
// rand::random::<i32>() % 100 // to remove - used to fake memory data on MacOS
-3
}
}
@@ -426,7 +428,7 @@ pub async fn handle_child(
mut child: Child,
nsjail: bool,
worker_name: &str,
_w_id: &str,
w_id: &str,
child_name: &str,
custom_timeout: Option<i32>,
sigterm: bool,
@@ -464,6 +466,8 @@ pub async fn handle_child(
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut i = 0;
let mut memory_metric_id: Result<String, Error> =
Err(Error::NotFound("not yet initialized".to_string()));
loop {
tokio::select!(
@@ -484,7 +488,29 @@ pub async fn handle_child(
if current_mem > *mem_peak {
*mem_peak = current_mem
}
tracing::info!("{worker_name}/{job_id} in {_w_id} still running. mem: {current_mem}kB, peak mem: {mem_peak}kB");
tracing::info!("{worker_name}/{job_id} in {w_id} still running. mem: {current_mem}kB, peak mem: {mem_peak}kB");
#[cfg(feature = "enterprise")]
{
// tracking metric starting at i >= 2 b/c first point it useless and we don't want to track metric for super fast jobs
if i == 2 {
memory_metric_id = job_metrics::register_metric_for_job(
&db,
w_id.to_string(),
job_id,
"memory_kb".to_string(),
job_metrics::MetricKind::TimeseriesInt,
Some("Job Memory Footprint (kB)".to_string()),
)
.await;
}
if let Ok(ref metric_id) = memory_metric_id {
if let Err(err) = job_metrics::record_metric(&db, w_id.to_string(), job_id, metric_id.to_owned(), job_metrics::MetricNumericValue::Integer(current_mem)).await {
tracing::error!("Unable to save memory stat for job {} in workspace {}. Error was: {:?}", job_id, w_id, err);
}
}
}
let (canceled, canceled_by, canceled_reason) = sqlx::query_as::<_, (bool, Option<String>, Option<String>)>("UPDATE queue SET mem_peak = $1, last_ping = now() WHERE id = $2 RETURNING canceled, canceled_by, canceled_reason")
.bind(*mem_peak)
.bind(job_id)
@@ -515,7 +541,7 @@ pub async fn handle_child(
}
let (timeout_duration, timeout_warn_msg) =
resolve_job_timeout(&db, _w_id, job_id, custom_timeout).await;
resolve_job_timeout(&db, w_id, job_id, custom_timeout).await;
if let Some(msg) = timeout_warn_msg {
logs.push_str(msg.as_str());
append_logs(job_id, msg.as_str(), db).await;
@@ -841,13 +867,26 @@ fn append_with_limit(dst: &mut String, src: &str, limit: &mut usize, remove_x00:
}
}
pub fn hash_args(v: &Option<sqlx::types::Json<HashMap<String, Box<RawValue>>>>) -> String {
pub async fn hash_args(
db: &DB,
client: &AuthedClient,
workspace_id: &str,
job_id: &Uuid,
v: &Option<sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
) -> String {
if let Some(vs) = v {
let mut dh = DefaultHasher::new();
let hm = &vs.0;
for k in hm.keys().sorted() {
k.hash(&mut dh);
hm.get(k).unwrap().get().hash(&mut dh);
let arg_value = hm.get(k).unwrap();
let arg_additions =
arg_value_hash_additions(db, client, workspace_id, job_id, hm.get(k).unwrap())
.await;
arg_value.get().hash(&mut dh);
for (_, arg_addition) in arg_additions {
arg_addition.hash(&mut dh);
}
}
hex::encode(dh.finish().to_be_bytes())
} else {
@@ -855,21 +894,152 @@ pub fn hash_args(v: &Option<sqlx::types::Json<HashMap<String, Box<RawValue>>>>)
}
}
#[derive(Serialize)]
struct StoreCachedResource<'a> {
expire: i64,
value: &'a RawValue,
async fn get_workspace_s3_resource_path(
db: &DB,
client: &AuthedClient,
workspace_id: &str,
job_id: &Uuid,
) -> Option<S3Resource> {
let raw_lfs_opt = sqlx::query_scalar!(
"SELECT large_file_storage FROM workspace_settings WHERE workspace_id = $1",
workspace_id
)
.fetch_one(db)
.await
.ok()
.flatten()
.map(|val| serde_json::from_value::<LargeFileStorage>(val).ok())
.flatten();
if let Some(LargeFileStorage::S3Storage(s3_storage)) = raw_lfs_opt {
let resource_path = s3_storage.s3_resource_path.trim_start_matches("$res:");
client
.get_resource_value_interpolated::<S3Resource>(&resource_path, Some(job_id.to_string()))
.await
.ok()
} else {
return None;
}
}
pub async fn save_in_cache<'a>(
async fn arg_value_hash_additions(
db: &DB,
client: &AuthedClient,
workspace_id: &str,
job_id: &Uuid,
raw_value: &Box<RawValue>,
) -> HashMap<String, String> {
let mut result: HashMap<String, String> = HashMap::new();
let parsed_s3_values: &mut Vec<S3Object> = &mut Vec::new();
extract_all_s3_object_from_raw_value(raw_value, parsed_s3_values);
if parsed_s3_values.is_empty() {
// no s3 object, nothing to return
return result;
}
let s3_resource_opt = get_workspace_s3_resource_path(db, client, workspace_id, job_id).await;
if let Some(s3_resource) = s3_resource_opt {
for s3_object in parsed_s3_values {
let etag = get_etag_or_empty(&s3_resource, s3_object.clone()).await;
tracing::warn!("Enriching s3 arg value with etag: {:?}", etag);
result.insert(s3_object.s3.clone(), etag.unwrap_or_default()); // TODO: maybe inject a random value to invalidate the cache?
}
}
return result;
}
fn extract_all_s3_object_from_raw_value(raw_value: &Box<RawValue>, result: &mut Vec<S3Object>) {
let parsed_value = serde_json::from_str::<S3Object>(raw_value.get());
if let Ok(parsed_value) = parsed_value {
result.push(parsed_value);
} else {
let parsed_value = serde_json::from_str::<HashMap<String, Box<RawValue>>>(raw_value.get());
if let Ok(parsed_value) = parsed_value {
for (_, v) in parsed_value {
extract_all_s3_object_from_raw_value(&v, result);
}
} else {
let parsed_value = serde_json::from_str::<Vec<Box<RawValue>>>(raw_value.get());
if let Ok(parsed_value) = parsed_value {
for v in parsed_value {
extract_all_s3_object_from_raw_value(&v, result);
}
}
}
}
}
#[derive(Deserialize, Serialize)]
struct CachedResource {
expire: i64,
#[serde(skip_serializing_if = "Option::is_none")]
s3_etags: Option<HashMap<String, String>>,
value: Box<RawValue>,
}
pub async fn get_cached_resource_value_if_valid(
db: &DB,
client: &AuthedClient,
job_id: &Uuid,
workspace_id: &str,
cached_res_path: &str,
) -> Option<Box<RawValue>> {
let resource_opt = client
.get_resource_value::<CachedResource>(cached_res_path)
.await
.ok();
if let Some(cached_resource) = resource_opt {
if cached_resource.expire <= chrono::Utc::now().timestamp() {
// cache expired
return None;
}
let s3_etags = cached_resource.s3_etags.unwrap_or_default();
let s3_resource_opt: Option<S3Resource> = if s3_etags.is_empty() {
None
} else {
get_workspace_s3_resource_path(db, &client, workspace_id, job_id).await
};
if !s3_etags.is_empty() && s3_resource_opt.is_none() {
tracing::warn!("Cached result references s3 files that are not retrievable anymore because the workspace S3 resource can't be fetched. Cache will be invalidated");
return None;
}
for (s3_file_key, s3_file_etag) in s3_etags {
if let Some(s3_resource) = s3_resource_opt.clone() {
let etag =
get_etag_or_empty(&s3_resource, S3Object { s3: s3_file_key.clone() }).await;
if etag.is_none() || etag.clone().unwrap() != s3_file_etag {
tracing::warn!("S3 file etag for '{}' has changed. Value from cache is {:?} while current value from S3 is {:?}. Cache will be invalidated", s3_file_key.clone(), s3_file_etag, etag);
return None;
}
}
}
return Some(cached_resource.value);
}
return None;
}
pub async fn save_in_cache(
db: &Pool<Postgres>,
client: &AuthedClient,
job: &QueuedJob,
cached_path: String,
r: &'a RawValue,
r: &Box<RawValue>,
) {
let expire = chrono::Utc::now().timestamp() + job.cache_ttl.unwrap() as i64;
let store_cache_resource = StoreCachedResource { expire, value: r };
let s3_etags =
arg_value_hash_additions(db, client, job.workspace_id.as_str(), &job.id, r).await;
let store_cache_resource = CachedResource {
expire,
s3_etags: if s3_etags.is_empty() {
None
} else {
Some(s3_etags)
},
value: r.clone(),
};
let raw_json = sqlx::types::Json(store_cache_resource);
if let Err(e) = sqlx::query!(
@@ -879,7 +1049,7 @@ pub async fn save_in_cache<'a>(
DO UPDATE SET value = $3",
job.workspace_id,
cached_path,
raw_json as sqlx::types::Json<StoreCachedResource>,
raw_json as sqlx::types::Json<CachedResource>,
"cache"
)
.execute(db)

View File

@@ -322,7 +322,13 @@ fn convert_val(value: &Value, arg_t: &String) -> windmill_common::error::Result<
Value::Number(n) if n.is_i64() && (arg_t == "smallint" || arg_t == "smallserial") => {
Ok(PgType::I16(n.as_i64().unwrap() as i16))
}
Value::Number(n) if n.is_i64() && (arg_t == "int" || arg_t == "serial") => {
Value::Number(n)
if n.is_i64()
&& (arg_t == "int"
|| arg_t == "integer"
|| arg_t == "int4"
|| arg_t == "serial") =>
{
Ok(PgType::I32(n.as_i64().unwrap() as i32))
}
Value::Number(n) if n.is_i64() && (arg_t == "numeric" || arg_t == "decimal") => Ok(
@@ -429,7 +435,6 @@ pub fn pg_cell_to_json_value(
Type::TS_VECTOR => get_basic(row, column, column_i, |a: StringCollector| {
Ok(JSONValue::String(a.0))
})?,
// array types
Type::BOOL_ARRAY => get_array(row, column, column_i, |a: bool| Ok(JSONValue::Bool(a)))?,
Type::INT2_ARRAY => get_array(row, column, column_i, |a: i16| {

View File

@@ -83,14 +83,24 @@ pub async fn pip_compile(
WORKER_CONFIG.read().await.pip_local_dependencies.as_ref()
{
let deps = pip_local_dependencies.clone();
let compiled_deps = deps.iter().map(|dep| {
let compiled_dep = Regex::new(dep);
match compiled_dep {
Ok(compiled_dep) => Some(compiled_dep),
Err(e) => {
tracing::warn!("regex compilation failed for Python local dependency: '{}' - it will be ignored", e);
return None;
}
}
}).filter(|dep_maybe| dep_maybe.is_some()).map(|dep| dep.unwrap()).collect::<Vec<Regex>>();
requirements
.lines()
.filter(|s| {
if !deps.contains(&s.to_string()) {
return true;
} else {
if compiled_deps.iter().any(|dep| dep.is_match(s)) {
logs.push_str(&format!("\nignoring local dependency: {}", s));
return false;
} else {
return true;
}
})
.join("\n")
@@ -566,7 +576,7 @@ async fn handle_python_deps(
};
if requirements.len() > 0 {
additional_python_paths = handle_python_reqs(
let mut venv_path = handle_python_reqs(
requirements
.split("\n")
.filter(|x| !x.starts_with("--"))
@@ -582,6 +592,7 @@ async fn handle_python_deps(
worker_dir,
)
.await?;
additional_python_paths.append(&mut venv_path);
}
Ok(additional_python_paths)
}

View File

@@ -25,6 +25,8 @@ use std::{
},
time::Duration,
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_parser_py_imports::parse_relative_imports;
use uuid::Uuid;
#[cfg(feature = "enterprise")]
@@ -33,9 +35,9 @@ use windmill_common::{
error::{self, to_anyhow, Error},
flows::{FlowModule, FlowModuleValue, FlowValue},
get_latest_deployed_hash_for_path,
jobs::{JobKind, QueuedJob},
jobs::{JobKind, JobPayload, QueuedJob},
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
users::{SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL},
users::{SUPERADMIN_NOTIFICATION_EMAIL, SUPERADMIN_SECRET_EMAIL, SUPERADMIN_SYNC_EMAIL},
utils::{rd_string, StripPath},
worker::{
to_raw_value, to_raw_value_owned, update_ping, CLOUD_HOSTED, WORKER_CONFIG, WORKER_GROUP,
@@ -76,7 +78,10 @@ use windmill_queue::{add_completed_job, add_completed_job_error};
use crate::{
bash_executor::{handle_bash_job, handle_powershell_job, ANSI_ESCAPE_RE},
bun_executor::{gen_lockfile, get_trusted_deps, handle_bun_job},
common::{build_args_map, hash_args, read_result, save_in_cache, write_file},
common::{
build_args_map, get_cached_resource_value_if_valid, hash_args, read_result, save_in_cache,
write_file,
},
deno_executor::{generate_deno_lock, handle_deno_job},
go_executor::{handle_go_job, install_go_dependencies},
graphql_executor::do_graphql,
@@ -146,7 +151,8 @@ pub async fn create_token_for_owner(
.await?
.unwrap_or(false)
|| email == SUPERADMIN_SECRET_EMAIL
|| email == SUPERADMIN_NOTIFICATION_EMAIL;
|| email == SUPERADMIN_NOTIFICATION_EMAIL
|| owner == SUPERADMIN_SYNC_EMAIL;
sqlx::query_scalar!(
"INSERT INTO token
@@ -469,6 +475,7 @@ async fn handle_receive_completed_job<
worker_name: &str,
worker_save_completed_job_duration: Option<Arc<prometheus::Histogram>>,
worker_flow_transition_duration: Option<Arc<prometheus::Histogram>>,
job_completed_tx: Sender<SendResult>,
) {
let token = jc.token.clone();
let workspace = jc.job.workspace_id.clone();
@@ -491,6 +498,7 @@ async fn handle_receive_completed_job<
worker_name,
worker_save_completed_job_duration,
worker_flow_transition_duration,
job_completed_tx.clone(),
)
.await
{
@@ -506,6 +514,7 @@ async fn handle_receive_completed_job<
&worker_dir,
rsmq.clone(),
worker_name,
job_completed_tx,
)
.await;
}
@@ -513,7 +522,7 @@ async fn handle_receive_completed_job<
#[derive(Clone)]
pub struct JobCompletedSender(
Sender<JobCompleted>,
Sender<SendResult>,
Option<Arc<GenericGauge<AtomicI64>>>,
Option<Arc<prometheus::Histogram>>,
);
@@ -522,12 +531,12 @@ impl JobCompletedSender {
pub async fn send(
&self,
jc: JobCompleted,
) -> Result<(), tokio::sync::mpsc::error::SendError<JobCompleted>> {
) -> Result<(), tokio::sync::mpsc::error::SendError<SendResult>> {
if let Some(wj) = self.1.as_ref() {
wj.inc()
}
let timer = self.2.as_ref().map(|x| x.start_timer());
let r = self.0.send(jc).await;
let r = self.0.send(SendResult::JobCompleted(jc)).await;
timer.map(|x| x.stop_and_record());
r
}
@@ -873,7 +882,7 @@ pub async fn run_worker<R: rsmq_async::RsmqConnection + Send + Sync + Clone + 's
let (same_worker_tx, mut same_worker_rx) = mpsc::channel::<Uuid>(5);
let (job_completed_tx, mut job_completed_rx) = mpsc::channel::<JobCompleted>(3);
let (job_completed_tx, mut job_completed_rx) = mpsc::channel::<SendResult>(3);
let job_completed_tx = JobCompletedSender(
job_completed_tx,
@@ -961,119 +970,167 @@ pub async fn run_worker<R: rsmq_async::RsmqConnection + Send + Sync + Clone + 's
let worker_name2 = worker_name.clone();
let killpill_tx2 = killpill_tx.clone();
let job_completed_sender = job_completed_tx.0.clone();
let send_result = tokio::spawn(async move {
while let Some(jc) = job_completed_rx.recv().await {
if let Some(wj) = worker_job_completed_channel_queue2.as_ref() {
wj.dec();
}
let base_internal_url2 = base_internal_url2.clone();
let worker_dir2 = worker_dir2.clone();
let db2 = db2.clone();
let same_worker_tx2 = same_worker_tx2.clone();
let rsmq2 = rsmq2.clone();
let worker_name = worker_name2.clone();
if matches!(jc.job.job_kind, JobKind::Noop) || is_dedicated_worker {
thread_count.fetch_add(1, Ordering::SeqCst);
let thread_count = thread_count.clone();
loop {
if thread_count.load(Ordering::Relaxed) < 4 {
break;
while let Some(sr) = job_completed_rx.recv().await {
match sr {
SendResult::JobCompleted(jc) => {
if let Some(wj) = worker_job_completed_channel_queue2.as_ref() {
wj.dec();
}
tokio::time::sleep(Duration::from_millis(3)).await;
}
let base_internal_url2 = base_internal_url2.clone();
let worker_dir2 = worker_dir2.clone();
let db2 = db2.clone();
let same_worker_tx2 = same_worker_tx2.clone();
let rsmq2 = rsmq2.clone();
let worker_name = worker_name2.clone();
if matches!(jc.job.job_kind, JobKind::Noop) || is_dedicated_worker {
thread_count.fetch_add(1, Ordering::SeqCst);
let thread_count = thread_count.clone();
#[cfg(feature = "benchmark")]
let send_duration = send_duration2.clone();
#[cfg(feature = "benchmark")]
let process_duration = process_duration.clone();
#[cfg(feature = "benchmark")]
let completed_jobs = completed_jobs.clone();
#[cfg(feature = "benchmark")]
let main_duration = main_duration2.clone();
let worker_save_completed_job_duration2 =
worker_save_completed_job_duration.clone();
let worker_flow_transition_duration2 = worker_flow_transition_duration.clone();
let killpill_tx = killpill_tx2.clone();
tokio::spawn(async move {
#[cfg(feature = "benchmark")]
let process_start = Instant::now();
let is_dependency_job = matches!(
jc.job.job_kind,
JobKind::Dependencies | JobKind::FlowDependencies
);
handle_receive_completed_job(
jc,
base_internal_url2,
db2.clone(),
worker_dir2,
same_worker_tx2,
rsmq2,
&worker_name,
worker_save_completed_job_duration2.clone(),
worker_flow_transition_duration2.clone(),
)
.await;
#[cfg(feature = "benchmark")]
{
let n = completed_jobs.fetch_add(1, Ordering::SeqCst);
if (n + 1) % 1000 == 0 || n == (jobs - 1) as usize {
let duration_s = start.elapsed().as_secs_f64();
let jobs_per_sec = n as f64 / duration_s;
tracing::info!(
"completed {} jobs in {}s, {} jobs/s",
n + 1,
duration_s,
jobs_per_sec
);
tracing::info!(
"main loop without send {}s",
main_duration.load(Ordering::SeqCst) as f64 / 1000.0
);
tracing::info!(
"send job completed / send dedicated job duration {}s",
send_duration.load(Ordering::SeqCst) as f64 / 1000.0
);
tracing::info!(
"job completed process duration {}s",
process_duration.load(Ordering::SeqCst) as f64 / 1000.0
);
loop {
if thread_count.load(Ordering::Relaxed) < 4 {
break;
}
tokio::time::sleep(Duration::from_millis(3)).await;
}
process_duration.fetch_add(
process_start.elapsed().as_millis() as usize,
Ordering::SeqCst,
);
}
#[cfg(feature = "benchmark")]
let send_duration = send_duration2.clone();
#[cfg(feature = "benchmark")]
let process_duration = process_duration.clone();
#[cfg(feature = "benchmark")]
let completed_jobs = completed_jobs.clone();
#[cfg(feature = "benchmark")]
let main_duration = main_duration2.clone();
thread_count.fetch_sub(1, Ordering::SeqCst);
if is_dependency_job && is_dedicated_worker {
tracing::error!("Dedicated worker executed a dependency job, a new script has been deployed. Exiting expecting to be restarted.");
sqlx::query!("UPDATE config SET config = config WHERE name = $1", format!("worker__{}", *WORKER_GROUP))
let worker_save_completed_job_duration2 =
worker_save_completed_job_duration.clone();
let worker_flow_transition_duration2 =
worker_flow_transition_duration.clone();
let killpill_tx = killpill_tx2.clone();
let job_completed_sender = job_completed_sender.clone();
tokio::spawn(async move {
#[cfg(feature = "benchmark")]
let process_start = Instant::now();
let is_dependency_job = matches!(
jc.job.job_kind,
JobKind::Dependencies | JobKind::FlowDependencies
);
handle_receive_completed_job(
jc,
base_internal_url2,
db2.clone(),
worker_dir2,
same_worker_tx2,
rsmq2,
&worker_name,
worker_save_completed_job_duration2.clone(),
worker_flow_transition_duration2.clone(),
job_completed_sender.clone(),
)
.await;
#[cfg(feature = "benchmark")]
{
let n = completed_jobs.fetch_add(1, Ordering::SeqCst);
if (n + 1) % 1000 == 0 || n == (jobs - 1) as usize {
let duration_s = start.elapsed().as_secs_f64();
let jobs_per_sec = n as f64 / duration_s;
tracing::info!(
"completed {} jobs in {}s, {} jobs/s",
n + 1,
duration_s,
jobs_per_sec
);
tracing::info!(
"main loop without send {}s",
main_duration.load(Ordering::SeqCst) as f64 / 1000.0
);
tracing::info!(
"send job completed / send dedicated job duration {}s",
send_duration.load(Ordering::SeqCst) as f64 / 1000.0
);
tracing::info!(
"job completed process duration {}s",
process_duration.load(Ordering::SeqCst) as f64 / 1000.0
);
}
process_duration.fetch_add(
process_start.elapsed().as_millis() as usize,
Ordering::SeqCst,
);
}
thread_count.fetch_sub(1, Ordering::SeqCst);
if is_dependency_job && is_dedicated_worker {
tracing::error!("Dedicated worker executed a dependency job, a new script has been deployed. Exiting expecting to be restarted.");
sqlx::query!("UPDATE config SET config = config WHERE name = $1", format!("worker__{}", *WORKER_GROUP))
.execute(&db2)
.await
.expect("update config to trigger restart of all dedicated workers at that config");
killpill_tx.send(()).unwrap_or_default();
killpill_tx.send(()).unwrap_or_default();
}
});
} else {
handle_receive_completed_job(
jc,
base_internal_url2,
db2,
worker_dir2,
same_worker_tx2,
rsmq2,
&worker_name,
worker_save_completed_job_duration2.clone(),
worker_flow_transition_duration2.clone(),
job_completed_sender.clone(),
)
.await;
}
});
} else {
handle_receive_completed_job(
jc,
base_internal_url2,
db2,
worker_dir2,
same_worker_tx2,
rsmq2,
&worker_name,
worker_save_completed_job_duration2.clone(),
worker_flow_transition_duration2.clone(),
)
.await;
}
SendResult::UpdateFlow {
flow,
w_id,
success,
result,
worker_dir,
stop_early_override,
token,
} => {
// let r;
if let Err(e) = update_flow_status_after_job_completion(
&db2,
&AuthedClient {
base_internal_url: base_internal_url2.to_string(),
workspace: w_id.clone(),
token: token.clone(),
force_client: None,
},
flow,
&Uuid::nil(),
&w_id,
success,
&result,
true,
same_worker_tx2.clone(),
&worker_dir,
stop_early_override,
rsmq2.clone(),
&worker_name2,
job_completed_sender.clone(),
)
.await
{
tracing::error!("Error updating flow status after job completion: {e}");
}
}
SendResult::Kill => {
break;
}
}
}
@@ -1330,6 +1387,7 @@ pub async fn run_worker<R: rsmq_async::RsmqConnection + Send + Sync + Clone + 's
}
}
println!("received killpill for worker {}", i_worker);
job_completed_tx.0.send(SendResult::Kill).await.unwrap();
break
},
_ = copy_to_bucket_rx.recv() => {
@@ -1597,6 +1655,7 @@ pub async fn run_worker<R: rsmq_async::RsmqConnection + Send + Sync + Clone + 's
&worker_dir,
rsmq.clone(),
&worker_name,
(&job_completed_tx.0).clone(),
)
.await;
};
@@ -2080,11 +2139,12 @@ pub async fn process_completed_job<R: rsmq_async::RsmqConnection + Send + Sync +
worker_name: &str,
worker_save_completed_job_duration: Option<Arc<prometheus::Histogram>>,
worker_flow_transition_duration: Option<Arc<prometheus::Histogram>>,
job_completed_tx: Sender<SendResult>,
) -> windmill_common::error::Result<()> {
if success {
// println!("bef completed job{:?}", SystemTime::now());
if let Some(cached_path) = cached_res_path {
save_in_cache(db, &job, cached_path.to_string(), &result).await;
save_in_cache(db, client, &job, cached_path.to_string(), &result).await;
}
let timer = worker_save_completed_job_duration
@@ -2123,6 +2183,7 @@ pub async fn process_completed_job<R: rsmq_async::RsmqConnection + Send + Sync +
None,
rsmq.clone(),
worker_name,
job_completed_tx,
)
.await?;
timer.map(|x| x.stop_and_record());
@@ -2158,6 +2219,7 @@ pub async fn process_completed_job<R: rsmq_async::RsmqConnection + Send + Sync +
None,
rsmq,
worker_name,
job_completed_tx,
)
.await?;
}
@@ -2213,6 +2275,7 @@ pub async fn handle_job_error<R: rsmq_async::RsmqConnection + Send + Sync + Clon
worker_dir: &str,
rsmq: Option<R>,
worker_name: &str,
job_completed_tx: Sender<SendResult>,
) {
let err = match err {
Error::JsonErr(err) => err,
@@ -2260,6 +2323,7 @@ pub async fn handle_job_error<R: rsmq_async::RsmqConnection + Send + Sync + Clon
None,
rsmq.clone(),
worker_name,
job_completed_tx.clone(),
)
.await;
@@ -2302,6 +2366,34 @@ fn extract_error_value(log_lines: &str, i: i32) -> Box<RawValue> {
);
}
pub enum SendResult {
JobCompleted(JobCompleted),
UpdateFlow {
flow: Uuid,
w_id: String,
success: bool,
result: Box<RawValue>,
worker_dir: String,
stop_early_override: Option<bool>,
token: String,
},
Kill,
}
// db: &DB,
// client: &AuthedClient,
// flow: uuid::Uuid,
// job_id_for_status: &Uuid,
// w_id: &str,
// success: bool,
// result: &'a RawValue,
// unrecoverable: bool,
// same_worker_tx: Sender<Uuid>,
// worker_dir: &str,
// stop_early_override: Option<bool>,
// rsmq: Option<R>,
// worker_name: &str,
#[derive(Debug, Clone)]
pub struct JobCompleted {
pub job: Arc<QueuedJob>,
@@ -2332,12 +2424,6 @@ async fn do_nativets(
Ok((result.0, [logs, result.1].join("\n\n")))
}
#[derive(Deserialize)]
struct CachedResource {
expire: i64,
value: Box<RawValue>,
}
#[derive(Deserialize, Serialize, Default)]
pub struct PreviousResult<'a> {
#[serde(borrow)]
@@ -2399,7 +2485,14 @@ async fn handle_queued_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
} else {
"none".to_string()
};
let args_hash = hash_args(&job.args);
let args_hash = hash_args(
db,
&client.get_authed().await,
&job.workspace_id,
&job.id,
&job.args,
)
.await;
if job.is_flow_step {
let flow_path = sqlx::query_scalar!(
"SELECT script_path FROM queue WHERE id = $1",
@@ -2424,33 +2517,32 @@ async fn handle_queued_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
if let Some(cached_res_path) = cached_res_path.clone() {
let authed_client = client.get_authed().await;
let resource = authed_client
.get_resource_value::<CachedResource>(&cached_res_path)
.await;
if let Ok(resource) = resource {
let expire = resource.expire;
if expire > chrono::Utc::now().timestamp() {
let result = resource.value;
let logs =
"Job skipped because args & path found in cache and not expired".to_string();
let cached_resource_value_maybe = get_cached_resource_value_if_valid(
db,
&authed_client,
&job.id,
&job.workspace_id,
&cached_res_path,
)
.await;
if let Some(cached_resource_value) = cached_resource_value_maybe {
let logs = "Job skipped because args & path found in cache and not expired".to_string();
job_completed_tx
.send(JobCompleted {
job: job,
result: cached_resource_value,
logs,
mem_peak: 0,
canceled_by: None,
success: true,
cached_res_path: None,
token: authed_client.token,
})
.await
.expect("send job completed");
job_completed_tx
.send(JobCompleted {
job: job,
result,
logs,
mem_peak: 0,
canceled_by: None,
success: true,
cached_res_path: None,
token: authed_client.token,
})
.await
.expect("send job completed");
return Ok(());
}
return Ok(());
}
};
match job.job_kind {
@@ -2464,7 +2556,7 @@ async fn handle_queued_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
same_worker_tx,
worker_dir,
rsmq,
worker_name,
job_completed_tx.0.clone(),
)
.await?;
timer.map(|x| x.stop_and_record());
@@ -2512,6 +2604,7 @@ async fn handle_queued_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
worker_dir,
base_internal_url,
&client.get_token().await,
rsmq.clone(),
)
.await
}
@@ -2526,6 +2619,7 @@ async fn handle_queued_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
worker_dir,
base_internal_url,
&client.get_token().await,
rsmq.clone(),
)
.await
.map(|()| serde_json::from_str("{}").unwrap()),
@@ -2540,6 +2634,7 @@ async fn handle_queued_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
worker_dir,
base_internal_url,
&client.get_token().await,
rsmq.clone(),
)
.await
.map(|()| serde_json::from_str("{}").unwrap()),
@@ -3041,7 +3136,7 @@ mount {{
}
#[tracing::instrument(level = "trace", skip_all)]
async fn handle_dependency_job(
async fn handle_dependency_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
job: &QueuedJob,
logs: &mut String,
mem_peak: &mut i32,
@@ -3052,7 +3147,17 @@ async fn handle_dependency_job(
worker_dir: &str,
base_internal_url: &str,
token: &str,
rsmq: Option<R>,
) -> error::Result<Box<RawValue>> {
let raw_code = sqlx::query_scalar!(
"SELECT content FROM script WHERE hash = $1 AND workspace_id = $2",
&job.script_hash.unwrap_or(ScriptHash(0)).0,
&job.workspace_id
)
.fetch_optional(db)
.await?
.unwrap_or_else(|| "No script found at this hash".to_string());
let script_path = job.script_path();
let content = capture_dependency_job(
&job.id,
job.language.as_ref().map(|v| Ok(v)).unwrap_or_else(|| {
@@ -3060,10 +3165,7 @@ async fn handle_dependency_job(
"Job Language required for dependency jobs".to_owned(),
))
})?,
job.raw_code
.as_ref()
.map(|a| a.as_str())
.unwrap_or_else(|| "no raw code"),
&raw_code,
logs,
mem_peak,
canceled_by,
@@ -3074,19 +3176,83 @@ async fn handle_dependency_job(
worker_dir,
base_internal_url,
token,
job.script_path(),
script_path,
)
.await;
match content {
Ok(content) => {
let hash = job.script_hash.unwrap_or(ScriptHash(0));
let w_id = &job.workspace_id;
sqlx::query!(
"UPDATE script SET lock = $1 WHERE hash = $2 AND workspace_id = $3",
&content,
&job.script_hash.unwrap_or(ScriptHash(0)).0,
&job.workspace_id
&hash.0,
w_id
)
.execute(db)
.await?;
let deployment_message = job.raw_code.as_ref().map(|x| x.clone());
if let Err(e) = handle_deployment_metadata(
&job.email,
&job.created_by,
&db,
&w_id,
DeployedObject::Script { hash, path: script_path.to_string() },
deployment_message.clone(),
rsmq.clone(),
)
.await
{
tracing::error!(%e, "error handling deployment metadata");
}
if &job.language == &Some(ScriptLang::Python3) {
if let Ok(relative_imports) = parse_relative_imports(&raw_code, script_path) {
logs.push_str("\n--- RELATIVE IMPORTS ---\n\n");
logs.push_str(&relative_imports.join("\n"));
if !relative_imports.is_empty() {
let mut tx = db.begin().await?;
sqlx::query!(
"DELETE FROM dependency_map
WHERE importer_path = $1 AND importer_kind = 'script'
AND workspace_id = $2",
script_path,
w_id
)
.execute(&mut *tx)
.await?;
for import in relative_imports {
sqlx::query!(
"INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path)
VALUES ($1, $2, 'script', $3)",
w_id,
script_path,
import
)
.execute(&mut *tx)
.await?;
logs.push_str(&format!("{}\n", import));
}
tx.commit().await?;
}
if let Err(e) = trigger_python_dependents_to_recompute_dependencies(
w_id,
script_path,
deployment_message,
&job.email,
&job.created_by,
&job.permissioned_as,
db,
rsmq,
)
.await
{
tracing::error!(%e, "error triggering python dependents to recompute dependencies");
}
}
}
Ok(to_raw_value_owned(
json!({ "success": "Successful lock file generation", "lock": content }),
))
@@ -3105,7 +3271,80 @@ async fn handle_dependency_job(
}
}
async fn handle_flow_dependency_job(
async fn trigger_python_dependents_to_recompute_dependencies<
R: rsmq_async::RsmqConnection + Send + Sync + Clone,
>(
w_id: &str,
script_path: &str,
deployment_message: Option<String>,
email: &str,
created_by: &str,
permissioned_as: &str,
db: &sqlx::Pool<sqlx::Postgres>,
rsmq: Option<R>,
) -> error::Result<()> {
let script_importers = sqlx::query_scalar!(
"SELECT importer_path FROM dependency_map
WHERE imported_path = $1 AND importer_kind = 'script'
AND workspace_id = $2",
script_path,
w_id
)
.fetch_all(db)
.await?;
for s in script_importers.iter() {
let tx: PushIsolationLevel<'_, R> =
PushIsolationLevel::IsolatedRoot(db.clone(), rsmq.clone());
let r = get_latest_deployed_hash_for_path(db, w_id, s.as_str()).await;
if let Ok(r) = r {
let (job_uuid, new_tx) = windmill_queue::push(
db,
tx,
&w_id,
JobPayload::Dependencies {
path: s.clone(),
hash: r.0,
language: r.5,
dedicated_worker: r.6,
deployment_message: deployment_message.clone(),
},
PushArgs::empty(),
&created_by,
email,
permissioned_as.to_string(),
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
)
.await?;
tracing::info!(
"pushed dependency job due to common python path: {job_uuid} for path {path} with hash {hash}",
path = s,
hash = r.0
);
new_tx.commit().await?;
} else {
tracing::error!(
"error getting latest deployed hash for path {path}: {err}",
path = s,
err = r.unwrap_err()
);
}
}
Ok(())
}
async fn handle_flow_dependency_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
job: &QueuedJob,
logs: &mut String,
mem_peak: &mut i32,
@@ -3116,6 +3355,7 @@ async fn handle_flow_dependency_job(
worker_dir: &str,
base_internal_url: &str,
token: &str,
rsmq: Option<R>,
) -> error::Result<()> {
let job_path = job.script_path.clone().ok_or_else(|| {
error::Error::InternalErr(
@@ -3167,6 +3407,21 @@ async fn handle_flow_dependency_job(
)
.execute(db)
.await?;
if let Err(e) = handle_deployment_metadata(
&job.email,
&job.created_by,
&db,
&job.workspace_id,
DeployedObject::Flow { path: job_path },
job.raw_code.as_ref().map(|x| x.clone()),
rsmq.clone(),
)
.await
{
tracing::error!(%e, "error handling deployment metadata");
}
Ok(())
}
@@ -3292,22 +3547,11 @@ async fn lock_modules(
new_flow_modules.push(e);
continue;
};
// sync with windmill-api/scripts
let dependencies = match language {
ScriptLang::Python3 => windmill_parser_py_imports::parse_python_imports(
&content,
&job.workspace_id,
&path.clone().unwrap_or_else(|| job_path.to_string()),
&db,
)
.await?
.join("\n"),
_ => content.clone(),
};
let new_lock = capture_dependency_job(
&job.id,
&language,
&dependencies,
&content,
logs,
mem_peak,
canceled_by,
@@ -3318,7 +3562,7 @@ async fn lock_modules(
worker_dir,
base_internal_url,
token,
job.script_path(),
&path.clone().unwrap_or_else(|| job_path.to_string()),
)
.await;
match new_lock {
@@ -3393,24 +3637,11 @@ async fn lock_modules_app(
.as_str()
.unwrap_or_default()
.to_string();
let dependencies = match language {
ScriptLang::Python3 => {
windmill_parser_py_imports::parse_python_imports(
&content,
&job.workspace_id,
job_path,
&db,
)
.await?
.join("\n")
}
_ => content.clone(),
};
logs.push_str("Found lockable inline script. Generating lock...\n");
let new_lock = capture_dependency_job(
&job.id,
&language,
&dependencies,
&content,
logs,
mem_peak,
canceled_by,
@@ -3495,7 +3726,7 @@ async fn lock_modules_app(
}
}
async fn handle_app_dependency_job(
async fn handle_app_dependency_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
job: &QueuedJob,
logs: &mut String,
mem_peak: &mut i32,
@@ -3506,6 +3737,7 @@ async fn handle_app_dependency_job(
worker_dir: &str,
base_internal_url: &str,
token: &str,
rsmq: Option<R>,
) -> error::Result<()> {
let job_path = job.script_path.clone().ok_or_else(|| {
error::Error::InternalErr(
@@ -3555,6 +3787,41 @@ async fn handle_app_dependency_job(
sqlx::query!("UPDATE app_version SET value = $1 WHERE id = $2", value, id,)
.execute(db)
.await?;
if let Err(e) = handle_deployment_metadata(
&job.email,
&job.created_by,
&db,
&job.workspace_id,
DeployedObject::App { path: job_path, version: id },
job.raw_code.as_ref().map(|x| x.clone()),
rsmq.clone(),
)
.await
{
tracing::error!(%e, "error handling deployment metadata");
}
// tx = PushIsolationLevel::Transaction(new_tx);
// tx = handle_deployment_metadata(
// tx,
// &authed,
// &db,
// &w_id,
// DeployedObject::App { path: app.path.clone(), version: v_id },
// app.deployment_message,
// )
// .await?;
// match tx {
// PushIsolationLevel::Transaction(tx) => tx.commit().await?,
// _ => {
// return Err(Error::InternalErr(
// "Expected a transaction here".to_string(),
// ));
// }
// }
Ok(())
} else {
Ok(())
@@ -3579,10 +3846,18 @@ async fn capture_dependency_job(
) -> error::Result<String> {
match job_language {
ScriptLang::Python3 => {
let reqs = windmill_parser_py_imports::parse_python_imports(
job_raw_code,
&w_id,
script_path,
&db,
)
.await?
.join("\n");
create_dependencies_dir(job_dir).await;
let req: std::result::Result<String, Error> = pip_compile(
job_id,
job_raw_code,
&reqs,
logs,
mem_peak,
canceled_by,

View File

@@ -15,9 +15,8 @@ use std::time::Duration;
use crate::common::{hash_args, save_in_cache};
use crate::js_eval::{eval_timeout, IdContext};
use crate::{AuthedClient, PreviousResult, KEEP_JOB_DIR};
use crate::{AuthedClient, PreviousResult, SendResult, KEEP_JOB_DIR};
use anyhow::Context;
use async_recursion::async_recursion;
use serde::Serialize;
use serde_json::value::RawValue;
use serde_json::{json, Value};
@@ -70,8 +69,10 @@ pub async fn update_flow_status_after_job_completion<
stop_early_override: Option<bool>,
rsmq: Option<R>,
worker_name: &str,
job_completed_tx: Sender<SendResult>,
) -> error::Result<()> {
// this is manual tailrecursion because async_recursion blows up the stack
// todo!();
let mut rec = update_flow_status_after_job_completion_internal(
db,
client,
@@ -87,6 +88,7 @@ pub async fn update_flow_status_after_job_completion<
false,
rsmq.clone(),
worker_name,
job_completed_tx.clone(),
)
.await?;
while let Some(nrec) = rec {
@@ -105,6 +107,7 @@ pub async fn update_flow_status_after_job_completion<
nrec.skip_error_handler,
rsmq.clone(),
worker_name,
job_completed_tx.clone(),
)
.await
{
@@ -125,6 +128,7 @@ pub async fn update_flow_status_after_job_completion<
nrec.skip_error_handler,
rsmq.clone(),
worker_name,
job_completed_tx.clone(),
)
.await?
}
@@ -167,6 +171,7 @@ pub async fn update_flow_status_after_job_completion_internal<
skip_error_handler: bool,
rsmq: Option<R>,
worker_name: &str,
job_completed_tx: Sender<SendResult>,
) -> error::Result<Option<RecUpdateFlowStatusAfterJobCompletion>> {
let (
should_continue_flow,
@@ -575,6 +580,8 @@ pub async fn update_flow_status_after_job_completion_internal<
}
tx.commit().await?;
tracing::debug!(id = %flow_job.id, root_id = %job_root, "flow status updated");
(
should_continue_flow,
flow_job,
@@ -633,7 +640,8 @@ pub async fn update_flow_status_after_job_completion_internal<
} else {
if flow_job.cache_ttl.is_some() {
let cached_res_path = {
let args_hash = hash_args(&flow_job.args);
let args_hash =
hash_args(db, client, w_id, job_id_for_status, &flow_job.args).await;
let flow_path = flow_job.script_path();
let version_hash = if let Some(rc) = flow_job.raw_flow.as_ref() {
use std::hash::Hasher;
@@ -648,7 +656,7 @@ pub async fn update_flow_status_after_job_completion_internal<
format!("{flow_path}/cache/{version_hash}/{args_hash}")
};
save_in_cache(db, &flow_job, cached_res_path, &nresult).await;
save_in_cache(db, client, &flow_job, cached_res_path, &nresult).await;
}
let success = success && !is_failure_step && !skip_error_handler;
if success {
@@ -685,6 +693,7 @@ pub async fn update_flow_status_after_job_completion_internal<
}
true
} else {
tracing::debug!(id = %flow_job.id, "start handle flow");
match handle_flow(
&flow_job,
db,
@@ -693,7 +702,7 @@ pub async fn update_flow_status_after_job_completion_internal<
same_worker_tx.clone(),
worker_dir,
rsmq.clone(),
worker_name,
job_completed_tx,
)
.await
{
@@ -1036,7 +1045,7 @@ pub async fn handle_flow<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
same_worker_tx: Sender<Uuid>,
worker_dir: &str,
rsmq: Option<R>,
worker_name: &str,
job_completed_tx: Sender<SendResult>,
) -> anyhow::Result<()> {
let flow = flow_job
.parse_raw_flow()
@@ -1055,7 +1064,7 @@ pub async fn handle_flow<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
same_worker_tx,
worker_dir,
rsmq,
worker_name,
job_completed_tx,
)
.await?;
Ok(())
@@ -1088,7 +1097,7 @@ pub struct RawArgs {
pub args: Option<Json<HashMap<String, Box<RawValue>>>>,
}
#[async_recursion]
// #[async_recursion]
// #[instrument(level = "trace", skip_all)]
async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>(
flow_job: &QueuedJob,
@@ -1100,7 +1109,7 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
same_worker_tx: Sender<Uuid>,
worker_dir: &str,
rsmq: Option<R>,
worker_name: &str,
job_completed_tx: Sender<SendResult>,
) -> error::Result<()> {
let job_root = flow_job
.root_job
@@ -1121,34 +1130,114 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
// if this is an empty module of if the module has already been completed, successfully, update the parent flow
if flow.modules.is_empty() || matches!(status_module, FlowStatusModule::Success { .. }) {
let r;
return update_flow_status_after_job_completion(
db,
client,
flow_job.id,
&Uuid::nil(),
flow_job.workspace_id.as_str(),
true,
if flow.modules.is_empty() {
r = to_raw_value(&flow_job_args);
&r
} else {
// it has to be an empty for loop event
serde_json::from_str("[]").unwrap()
},
true,
same_worker_tx,
worker_dir,
None,
rsmq,
worker_name,
)
.await;
job_completed_tx
.send(SendResult::UpdateFlow {
flow: flow_job.id,
success: true,
result: if flow.modules.is_empty() {
to_raw_value(&flow_job_args)
} else {
// it has to be an empty for loop event
serde_json::from_str("[]").unwrap()
},
stop_early_override: None,
w_id: flow_job.workspace_id.clone(),
worker_dir: worker_dir.to_string(),
token: client.token.clone(),
})
.await
.map_err(|e| {
Error::InternalErr(format!(
"error sending update flow message to job completed channel: {e}"
))
})?;
// let r;
// return update_flow_status_after_job_completion(
// db,
// client,
// flow_job.id,
// &Uuid::nil(),
// flow_job.workspace_id.as_str(),
// true,
// if flow.modules.is_empty() {
// r = to_raw_value(&flow_job_args);
// &r
// } else {
// // it has to be an empty for loop event
// serde_json::from_str("[]").unwrap()
// },
// true,
// same_worker_tx,
// worker_dir,
// None,
// rsmq,
// worker_name,
// )
// .await;
return Ok(());
}
let arc_flow_job_args = Arc::new(flow_job_args.clone());
if i == 0 {
if !flow_job.is_flow_step && flow_job.schedule_path.is_some() {
let no_flow_overlap = sqlx::query_scalar!(
"SELECT no_flow_overlap FROM schedule WHERE path = $1 AND workspace_id = $2",
flow_job.schedule_path.as_ref().unwrap(),
flow_job.workspace_id.as_str()
)
.fetch_one(db)
.await?;
if no_flow_overlap {
let count = sqlx::query_scalar!(
"SELECT count(*) FROM queue WHERE schedule_path = $1 AND workspace_id = $2 AND id != $3",
flow_job.schedule_path.as_ref().unwrap(),
flow_job.workspace_id.as_str(),
flow_job.id
).fetch_one(db).await?.unwrap_or(0);
if count > 0 {
job_completed_tx
.send(SendResult::UpdateFlow {
flow: flow_job.id,
success: true,
result: serde_json::from_str(
"\"not allowed to overlap, scheduling next iteration\"",
)
.unwrap(),
stop_early_override: Some(true),
w_id: flow_job.workspace_id.clone(),
worker_dir: worker_dir.to_string(),
token: client.token.clone(),
})
.await
.map_err(|e| {
Error::InternalErr(format!(
"error sending update flow message to job completed channel: {e}"
))
})?;
// return update_flow_status_after_job_completion(
// db,
// client,
// flow_job.id,
// &Uuid::nil(),
// flow_job.workspace_id.as_str(),
// true,
// serde_json::from_str(
// "\"not allowed to overlap, scheduling next iteration\"",
// )
// .unwrap(),
// true,
// same_worker_tx,
// worker_dir,
// Some(true),
// rsmq,
// worker_name,
// )
// .await;
return Ok(());
}
}
}
if let Some(skip_expr) = &flow.skip_expr {
let skip = compute_bool_from_expr(
skip_expr.to_string(),
@@ -1160,22 +1249,40 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
)
.await?;
if skip {
return update_flow_status_after_job_completion(
db,
client,
flow_job.id,
&Uuid::nil(),
flow_job.workspace_id.as_str(),
true,
serde_json::from_str("\"stopped early\"").unwrap(),
true,
same_worker_tx,
worker_dir,
Some(true),
rsmq,
worker_name,
)
.await;
job_completed_tx
.send(SendResult::UpdateFlow {
flow: flow_job.id,
success: true,
result: serde_json::from_str("\"stopped early\"").unwrap(),
stop_early_override: Some(true),
w_id: flow_job.workspace_id.clone(),
worker_dir: worker_dir.to_string(),
token: client.token.clone(),
})
.await
.map_err(|e| {
Error::InternalErr(format!(
"error sending update flow message to job completed channel: {e}"
))
})?;
// return update_flow_status_after_job_completion(
// db,
// client,
// flow_job.id,
// &Uuid::nil(),
// flow_job.workspace_id.as_str(),
// true,
// serde_json::from_str("\"stopped early\"").unwrap(),
// true,
// same_worker_tx,
// worker_dir,
// Some(true),
// rsmq,
// worker_name,
// )
// .await;
return Ok(());
}
}
}
@@ -1397,22 +1504,39 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
.await?;
if flow_job.is_flow_step {
if let Some(parent_job) = flow_job.parent_job {
update_flow_status_after_job_completion(
db,
client,
parent_job,
&flow_job.id,
&flow_job.workspace_id,
true,
&to_raw_value(&result),
false,
same_worker_tx.clone(),
&worker_dir,
None,
rsmq,
worker_name,
)
.await?;
job_completed_tx
.send(SendResult::UpdateFlow {
flow: parent_job,
success: true,
result: to_raw_value(&result),
stop_early_override: Some(true),
w_id: flow_job.workspace_id.clone(),
worker_dir: worker_dir.to_string(),
token: client.token.clone(),
})
.await
.map_err(|e| {
Error::InternalErr(format!(
"error sending update flow message to job completed channel: {e}"
))
})?;
// update_flow_status_after_job_completion(
// db,
// client,
// parent_job,
// &flow_job.id,
// &flow_job.workspace_id,
// true,
// &to_raw_value(&result),
// false,
// same_worker_tx.clone(),
// &worker_dir,
// None,
// rsmq,
// worker_name,
// )
// .await?;
return Ok(());
}
}
return Ok(());
@@ -1650,6 +1774,7 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
_ => Ok(flow_job_args),
}
};
tracing::debug!(id = %flow_job.id, root_id = %job_root, "flow job args computed");
let next_flow_transform = compute_next_flow_transform(
arc_flow_job_args.clone(),
@@ -1850,6 +1975,8 @@ async fn push_next_flow_job<R: rsmq_async::RsmqConnection + Send + Sync + Clone>
)
.await?;
tracing::debug!(id = %flow_job.id, root_id = %job_root, "pushed next flow job: {uuid}");
if let FlowModuleValue::ForloopFlow { parallelism: Some(p), .. } = &module.value {
if i as u16 >= *p {
sqlx::query!(
@@ -2172,6 +2299,8 @@ async fn compute_next_flow_transform(
))
};
let delete_after_use = module.delete_after_use.unwrap_or(false);
tracing::debug!(id = %flow_job.id, "computing next flow transform for {:?}", &module.value);
match &module.value {
FlowModuleValue::Identity => trivial_next_job(JobPayload::Identity),
FlowModuleValue::Flow { path, .. } => {

View File

@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.231.0";
export const VERSION = "v1.236.2";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({

View File

@@ -31,7 +31,7 @@ addEventListener("error", (event) => {
}
});
export const VERSION = "v1.231.0";
export const VERSION = "v1.236.2";
let command: any = new Command()
.name("wmill")

View File

@@ -14,6 +14,7 @@ import {
writeAllSync,
yamlParse,
} from "./deps.ts";
import { deepEqual } from "./utils.ts";
export interface ScriptFile {
parent_hash?: string;
@@ -66,7 +67,7 @@ export async function handleFile(
path: string,
workspace: string,
alreadySynced: string[],
message?: string,
message?: string
): Promise<boolean> {
if (
!path.includes(".inline_script.") &&
@@ -125,8 +126,9 @@ export async function handleFile(
typed.is_template === remote.is_template &&
typed.kind == remote.kind &&
!remote.archived &&
(remote?.lock ?? "") == (typed.lock?.join("\n") ?? "") &&
JSON.stringify(typed.schema) == JSON.stringify(remote.schema) &&
(remote?.lock ?? "").trim() ==
(typed.lock?.join("\n") ?? "").trim() &&
deepEqual(typed.schema, remote.schema) &&
typed.tag == remote.tag &&
(typed.ws_error_handler_muted ?? false) ==
remote.ws_error_handler_muted &&
@@ -140,6 +142,7 @@ export async function handleFile(
return true;
}
}
log.info(
colors.yellow.bold(`Creating script with a parent ${remotePath}`)
);

View File

@@ -478,7 +478,7 @@ async function pull(
!opts.json
);
const local = opts.raw
? undefined
? await FSFSElement(Deno.cwd())
: await FSFSElement(path.join(Deno.cwd(), ".wmill"));
const changes = await compareDynFSElement(
remote,
@@ -685,19 +685,18 @@ async function push(
"Computing the files to update on the remote to match local (taking .wmillignore into account)"
)
);
const remote = opts.raw
? undefined
: ZipFSElement(
(await downloadZip(
workspace,
opts.plainSecrets,
opts.skipVariables,
opts.skipResources,
opts.skipSecrets,
opts.includeSchedules
))!,
!opts.json
);
const remote = ZipFSElement(
(await downloadZip(
workspace,
opts.plainSecrets,
opts.skipVariables,
opts.skipResources,
opts.skipSecrets,
opts.includeSchedules
))!,
!opts.json
);
const local = await FSFSElement(path.join(Deno.cwd(), ""));
const changes = await compareDynFSElement(
local,
@@ -850,7 +849,7 @@ async function push(
case "flow":
await FlowService.deleteFlowByPath({
workspace: workspaceId,
path: removeSuffix(change.path, ".flow.json"),
path: removeSuffix(change.path, ".flow/flow.json"),
});
break;
case "app":

View File

@@ -0,0 +1,124 @@
Windmill monitoring with Prometheus
===================================
## Intro
Windmill servers and workers both produces metrics Prometheus can scrap.
Individual jobs producing their own metric can be difficult to achieve due to the ephemeral nature of a job. Prometheus might not have the time to scrape the metric if the job is too quick. Prometheus has developed the [Prometheus Gateway](https://prometheus.io/docs/practices/pushing/) exactly for this usecase, and we're going to go through the initial setup. By the end of this example, you'll have a Windmill stack running a job that produces its own metric, and a Grafana simple dashboard displaying it.
Prometheus push gateway is hosted in [this repository](https://github.com/prometheus/pushgateway)
## Setup
First, we need to setup an entire stack composed of:
- Windmill: a database, a server, and at least one worker
- Prometheus: the server (if you don't already have one), and the gateway to which metrics will be pushed
- Grafana to visualize the metrics (optional)
We have assembled a docker compose with all those services. You can run it with a simple:
```bash
docker compose up -d
```
Once all the services are running, Windmill can be accessed at http://localhost:8000, Prometheus at http://localhost:9090, Grafana at http://localhost:3000 (credentials are set in [grafana.ini](./grafana/grafana.ini) config file).
The Gateway exposes the metrics on http://localhost:9091/metrics and also has an interface at http://localhost:9091 to manage metric groups (see the Gateway documentation for what a metric group is). You can check that Prometheus can collect them directly on [Prometheus status page](http://localhost:9090/targets)
## Monitoring Windmill servers and workers
Metrics need to be enabled on Windmill server and workers. They can be enabled only on Windmill Enterprise Edition by setting the environment variable `METRICS_ADDR` to `1` on each container or toggle on "Expose Metrics" in instance settings -> core (additional "debug-level" metrics can be enabled in the instance settings > debug menu).
Once metrics are enabled, Prometheus needs to discover Windmill service containers. Using docker-compose, it requires a few adjustments:
- Each Windmill containers need to expose the metrics port (`8001` by default) so that Prometheus discovery knows which ports to scrap. This is done by adding the value `8001` to the `expose` block to both `windmill_server` and `windmill_worker`.
- Each Windmill container needs to be labelled so that Prometheus service discovery filters out other services. Here we're using the `prometheus-job=windmill_server` and `prometheus-job=windmill_worker` in the docker compose to differentiate between server and worker
- The Prometheus container needs to have access to the docker socket to discover other containers. To achieve this, Prometheus container needs to be run as root and the docker socket needs to be mounted on the container (see `user: root` and `/var/run/docker.sock:/var/run/docker.sock` in the `prometheus` block of docker-compose.yml)
- Finally, the following block should be added to `prometheus.yml` config file:
```yaml
scrape_configs:
- job_name: "windmill_server"
docker_sd_configs:
- host: unix:///var/run/docker.sock
relabel_configs:
- source_labels: [__meta_docker_container_label_prometheus_job]
regex: windmill_server
action: keep
scrape_interval: 1s
- job_name: "windmill_worker"
docker_sd_configs:
- host: unix:///var/run/docker.sock
relabel_configs:
- source_labels: [__meta_docker_container_label_prometheus_job]
regex: windmill_worker
action: keep
scrape_interval: 1s
```
With all this in place, Windmill servers and workers metrics will be scrapped by Prometheus and can be visualized in Grafana. We've added a simple dashboard in [grafana/dashboards/](./grafana/dashboards) showing the most interesting metrics.
![Windmill monitoring dashboard](./static/windmill_monitoring_dashboard.png "Windmill monitoring dashboard")
Note: If your Windmill containers are split onto multiple docker daemon, the above still works. Instead of pointing to the docker socket in Prometheus' `docker_sd_config` block, simply expose docker daemon to http or https and point to the remote docker daemon host: `http://remote_docker_daemon:2375`. Multiple remote docker daemons can be listed.
## Producing metrics from individual Windmill jobs
Here is an example of how a job can "push" its own metrics to Prometheus via Prometheus Gateway
### Windmill script
Now we will create a script in Windmill that pushes a value to the Prometheus Gateway. We will do it in Python using [prometheus-client](https://github.com/prometheus/client_python), but other clients are available for [Golang](https://github.com/prometheus/client_golang) or [Typescript](https://github.com/siimon/prom-client), and for bash metrics can be pushed via [simple CURL commands](https://github.com/prometheus/pushgateway?tab=readme-ov-file#command-line).
In Windmill, create a new Python script (named `u/admin/random_number_metric_script` in this tutorial) with the following content:
```python
import os
import random
from prometheus_client import CollectorRegistry, Gauge, push_to_gateway
PROMETHEUS_GATEWAY_URL = "prometheus_gateway:9091"
def main():
job_path = os.environ.get("WM_JOB_PATH")
registry = CollectorRegistry()
gauge = Gauge(
"job_records_processed",
"Number of records processed for {}".format(job_path),
registry=registry,
)
val = random.randint(0, 100)
print("Storing metrics value: ", val)
gauge.set(val)
push_to_gateway(PROMETHEUS_GATEWAY_URL, job=job_path, registry=registry)
```
It is quite simple. It generate a random number and pushed it via a Gauge metric named `job_records_processed` to the Prometheus Gateway. Note that we use the script path as the `job` label, so that all runs of this script will push to the same metric. Other labels (such as the job ID for example) can also be used via the `grouping_key` argument that `push_to_gateway` accepts.
Once the script is created, you can create a schedule that runs is every 5 seconds for example, such that a new value is pushed regularly.
### Visualizing the metric
If you chose to not run Grafana, the raw metric value can be seen in Prometheus UI directly. Go to http://localhost:9090/graph and input the following in the Expression bar:
```promql
job_records_processed{exported_job="u/admin/random_number_metric_script"}
```
![Metric visualization in Prometheus UI](./static/prometheus_metric_viz.png "Metric visualization in Prometheus UI")
In Grafana, a similar visualization can be achieved. We've added a dashboard JSON in [grafana/dashboards](./grafana/dashboards/) and the easiest is to import it directly in your Grafana.
The first panel is a simple representation of the `job_records_processed` metric. We've adjusted the `Min Step` to 5 seconds because we know our job runs every 5 seconds and we ideally want to display all the measurement points.
![Job records processed raw metric](./static/job_records_processed_raw.png "Job records processed raw metric")
The second panel is here to highlight a limitation to be aware of when using the Prometheus Gateway. It displays the instant value of the `job_records_processed` metric. The main issue with using the Gateway is that the value pushed to it will persist until a new value is pushed. Which means that if the schedule is somehow stopped, or if the script starts failing and stops pushing to Prometheus Gateway, the value will remain constant. It might not be ideal to spot any kind of undesired behavior. Instead what would be nice is to have the metric drop to a default value, like `0`. Thankfully the Gateway comes with a built-in metric called `push_time_seconds` which stores the timestamp of the last successful push for any given metric and label. You can combine the 2 metrics using basic functions to easily create an "instant" representation of signal. This is what we've done in the second panel:
![Job records processed instant metric](./static/job_records_processed_instant.png "Job records processed instant metric")
We simply multiply the `job_records_processed` with the `irate` of the `push_time_seconds` metric for the same label, and we set the `Min Step` to `1s` for the `irate` to properly work.
We obtain the following simple yet useful dashboard:
![Grafana dashboard with the 2 panels](./static/grafana_dashboard.png "Grafana dashboard with the 2 panels")
In between the 2 light blue vertical lines, we manually stopped the Windmill schedule to show that the raw metric (at the top) remains constant, while its instant representation drops to zero.

View File

@@ -0,0 +1,100 @@
version: "3.7"
services:
db:
image: postgres:14
volumes:
- db_data:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: windmill
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
windmill_server:
image: ghcr.io/windmill-labs/windmill-ee:main
deploy:
replicas: 1
labels:
- prometheus-job=windmill_server
expose:
- 8001
ports:
- 8000:8000
environment:
- DATABASE_URL=postgres://postgres:changeme@db/windmill?sslmode=disable
- MODE=server
- METRICS_ADDR=1
depends_on:
db:
condition: service_healthy
windmill_worker:
image: ghcr.io/windmill-labs/windmill-ee:main
pull_policy: always
deploy:
replicas: 3
labels:
- prometheus-job=windmill_worker
expose:
- 8001
restart: unless-stopped
environment:
- DATABASE_URL=postgres://postgres:changeme@db/windmill?sslmode=disable
- MODE=worker
- WORKER_GROUP=default
- METRICS_ADDR=1
depends_on:
db:
condition: service_healthy
prometheus:
image: prom/prometheus
user: root
volumes:
- prometheus_data:/prometheus
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
- /var/run/docker.sock:/var/run/docker.sock:ro # for service discovery
ports:
- 9090:9090
environment:
POSTGRES_PASSWORD: changeme
POSTGRES_DB: windmill
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=365d"
prometheus_gateway:
image: prom/pushgateway
ports:
- 9091:9091
command:
- "--persistence.file=/data/persistence.dat"
volumes:
- prometheus_gateway_data:/data
grafana:
image: grafana/grafana
container_name: grafana
environment:
- GF_PATHS_CONFIG=/etc/grafana/grafana.ini
deploy:
replicas: 1
depends_on:
- prometheus
ports:
- 3030:3000
volumes:
- grafana_data:/var/lib/grafana
- ./grafana/datasource.yml:/etc/grafana/provisioning/datasources/datasource.yml
- ./grafana/grafana.ini:/etc/grafana/grafana.ini
volumes:
db_data: null
prometheus_data: null
prometheus_gateway_data: null
grafana_data: null

View File

@@ -0,0 +1,319 @@
{
"__inputs": [
{
"name": "DS_PROMETHEUS",
"label": "Prometheus",
"description": "",
"type": "datasource",
"pluginId": "prometheus",
"pluginName": "Prometheus"
}
],
"__elements": {},
"__requires": [
{
"type": "grafana",
"id": "grafana",
"name": "Grafana",
"version": "10.2.3"
},
{
"type": "datasource",
"id": "prometheus",
"name": "Prometheus",
"version": "1.0.0"
},
{
"type": "panel",
"id": "timeseries",
"name": "Time series",
"version": ""
}
],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 0
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"disableTextWrap": false,
"editorMode": "builder",
"expr": "job_records_processed{exported_job=\"$script_path\"}",
"fullMetaSearch": false,
"includeNullMetadata": true,
"instant": false,
"interval": "5s",
"legendFormat": "__auto",
"range": true,
"refId": "A",
"useBackend": false
}
],
"title": "Processed records (raw)",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"description": "",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "bars",
"fillOpacity": 100,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": true,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 17,
"w": 24,
"x": 0,
"y": 7
},
"id": 2,
"interval": "15s",
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"disableTextWrap": false,
"editorMode": "code",
"expr": "irate(push_time_seconds{exported_job=\"$script_path\"}[$__interval]) * job_records_processed{exported_job=\"$script_path\"} / irate(push_time_seconds{exported_job=\"$script_path\"}[$__interval])",
"fullMetaSearch": false,
"hide": false,
"includeNullMetadata": true,
"instant": true,
"interval": "1s",
"legendFormat": "__auto",
"range": true,
"refId": "B",
"useBackend": false
}
],
"title": "Processed records (instant)",
"transformations": [],
"type": "timeseries"
}
],
"refresh": "5s",
"schemaVersion": 39,
"tags": [],
"templating": {
"list": [
{
"current": {
"selected": false,
"text": "Prometheus",
"value": "PBFA97CFB590B2093"
},
"hide": 0,
"includeAll": false,
"multi": false,
"name": "prometheus",
"options": [],
"query": "prometheus",
"queryValue": "",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"type": "datasource"
},
{
"current": {},
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"definition": "label_values(exported_job)",
"hide": 1,
"includeAll": false,
"multi": false,
"name": "script_path",
"options": [],
"query": {
"qryType": 1,
"query": "label_values(exported_job)",
"refId": "PrometheusVariableQueryEditor-VariableQuery"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"type": "query"
}
]
},
"time": {
"from": "now-15m",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Job metrics Dashboard",
"uid": "a31424a8-5eaa-4d26-bded-19570145b7bd",
"version": 8,
"weekStart": ""
}

View File

@@ -0,0 +1,7 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090

View File

@@ -0,0 +1,3 @@
[security]
admin_user = windmill
admin_password = changeme

View File

@@ -0,0 +1,28 @@
global:
scrape_interval: 1s
external_labels:
env: "infrastructure"
scrape_configs:
- job_name: "windmill_server"
docker_sd_configs:
- host: unix:///var/run/docker.sock
relabel_configs:
- source_labels: [__meta_docker_container_label_prometheus_job]
regex: windmill_server
action: keep
scrape_interval: 1s
- job_name: "windmill_worker"
docker_sd_configs:
- host: unix:///var/run/docker.sock
relabel_configs:
- source_labels: [__meta_docker_container_label_prometheus_job]
regex: windmill_worker
action: keep
scrape_interval: 1s
- job_name: "prometheus_gateway"
static_configs:
- targets: ["prometheus_gateway:9091"]
scrape_interval: 1s

Some files were not shown because too many files have changed in this diff Show More