Compare commits
2 Commits
v1.628.1
...
fg/utilsbu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7961e39ad | ||
|
|
4f70b5c1d3 |
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Format backend Rust files with rustfmt after Claude edits them
|
||||
|
||||
# Get the file path from the tool result (passed via stdin as JSON)
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# Exit if no file path
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if the file is in the backend directory and is a Rust file
|
||||
if [[ "$FILE_PATH" == *"/backend/"* ]] && [[ "$FILE_PATH" =~ \.rs$ ]]; then
|
||||
cd "$CLAUDE_PROJECT_DIR/backend" || exit 0
|
||||
# Run rustfmt with config from rustfmt.toml (edition=2021)
|
||||
rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Format frontend files with prettier after Claude edits them
|
||||
|
||||
# Get the file path from the tool result (passed via stdin as JSON)
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
# Exit if no file path
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if the file is in the frontend directory
|
||||
if [[ "$FILE_PATH" == *"/frontend/"* ]]; then
|
||||
# Check if it's a formattable file type
|
||||
if [[ "$FILE_PATH" =~ \.(ts|js|svelte|json|css|html|md)$ ]]; then
|
||||
cd "$CLAUDE_PROJECT_DIR/frontend" || exit 0
|
||||
# Run prettier silently, don't fail the hook if prettier fails
|
||||
npx prettier --write "$FILE_PATH" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Notify user when Claude requires input (works on macOS and Linux)
|
||||
|
||||
# Check if we're in an SSH session
|
||||
if [[ -n "$SSH_CLIENT" || -n "$SSH_TTY" || -n "$SSH_CONNECTION" ]]; then
|
||||
# SSH session - use terminal bell
|
||||
# If using VSCode, enable audible terminal bell for SSH sessions:
|
||||
# Add the following to .vscode/settings.json:
|
||||
# "accessibility.signals.terminalBell": {
|
||||
# "sound": "on"
|
||||
# },
|
||||
# "terminal.integrated.enableVisualBell": true
|
||||
printf '\a'
|
||||
else
|
||||
# Local session - use native notifications
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
osascript -e 'display notification "Claude is waiting for your input" with title "Claude Code" sound name "Glass"' 2>/dev/null || printf '\a'
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
notify-send "Claude Code" "Claude is waiting for your input" 2>/dev/null || printf '\a'
|
||||
else
|
||||
printf '\a'
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -23,11 +23,7 @@
|
||||
"Bash(git log:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(git show:*)",
|
||||
"Bash(git blame:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Bash(npm run generate-backend-client:*)",
|
||||
"Bash(npm run check:*)"
|
||||
"Bash(git blame:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Read(.env)",
|
||||
@@ -95,34 +91,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-frontend.sh",
|
||||
"timeout": 30
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-backend.sh",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Notification": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/notify-user.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
|
||||
2
.github/DockerfileBackendTests
vendored
2
.github/DockerfileBackendTests
vendored
@@ -42,7 +42,7 @@ RUN wget https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VER
|
||||
RUN /usr/local/bin/python3 -m pip install pip-tools
|
||||
|
||||
# Bun
|
||||
COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
|
||||
COPY --from=oven/bun:1.2.23 /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
|
||||
8
.github/workflows/backend-check.yml
vendored
8
.github/workflows/backend-check.yml
vendored
@@ -33,10 +33,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: install xmlsec1 and gssapi
|
||||
- name: install xmlsec1
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev
|
||||
sudo apt-get install -y libxml2-dev libxmlsec1-dev
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
@@ -100,10 +100,10 @@ jobs:
|
||||
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: install xmlsec1 and gssapi
|
||||
- name: install xmlsec1
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev
|
||||
sudo apt-get install -y libxml2-dev libxmlsec1-dev
|
||||
|
||||
- name: Substitute EE code (EE logic is behind feature flag)
|
||||
run: |
|
||||
|
||||
117
.github/workflows/backend-test.yml
vendored
117
.github/workflows/backend-test.yml
vendored
@@ -44,10 +44,7 @@ jobs:
|
||||
go-version: 1.21.5
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.8
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
bun-version: 1.1.43
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.9.24"
|
||||
@@ -70,111 +67,6 @@ jobs:
|
||||
- name: Substitute EE code (EE logic is behind feature flag)
|
||||
run: |
|
||||
./substitute_ee_code.sh --copy --dir ./windmill-ee-private
|
||||
- name: Setup private npm registry with test package
|
||||
working-directory: /tmp
|
||||
run: |
|
||||
set -e
|
||||
|
||||
# Install Verdaccio globally
|
||||
npm install -g verdaccio
|
||||
|
||||
# Create Verdaccio config that requires authentication for @windmill-test packages
|
||||
mkdir -p /tmp/verdaccio/storage
|
||||
cat > /tmp/verdaccio/config.yaml << 'VERDACCIO_CONFIG'
|
||||
storage: /tmp/verdaccio/storage
|
||||
auth:
|
||||
htpasswd:
|
||||
file: /tmp/verdaccio/htpasswd
|
||||
max_users: 100
|
||||
uplinks:
|
||||
npmjs:
|
||||
url: https://registry.npmjs.org/
|
||||
packages:
|
||||
'@windmill-test/*':
|
||||
access: $authenticated
|
||||
publish: $authenticated
|
||||
'@*/*':
|
||||
access: $all
|
||||
publish: $authenticated
|
||||
proxy: npmjs
|
||||
'**':
|
||||
access: $all
|
||||
publish: $authenticated
|
||||
proxy: npmjs
|
||||
server:
|
||||
keepAliveTimeout: 60
|
||||
middlewares:
|
||||
audit:
|
||||
enabled: true
|
||||
log: { type: stdout, format: pretty, level: warn }
|
||||
VERDACCIO_CONFIG
|
||||
|
||||
# Create empty htpasswd file (users will be created via API)
|
||||
touch /tmp/verdaccio/htpasswd
|
||||
|
||||
# Start Verdaccio in background
|
||||
verdaccio --config /tmp/verdaccio/config.yaml &
|
||||
VERDACCIO_PID=$!
|
||||
|
||||
# Wait for Verdaccio to be ready
|
||||
echo "Waiting for Verdaccio to start..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:4873/-/ping > /dev/null 2>&1; then
|
||||
echo "Verdaccio is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Login to get a token
|
||||
echo "Getting auth token..."
|
||||
RESPONSE=$(curl -s -X PUT \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"testuser","password":"testpass123"}' \
|
||||
http://localhost:4873/-/user/org.couchdb.user:testuser)
|
||||
|
||||
echo "Auth response: $RESPONSE"
|
||||
NPM_TOKEN=$(echo "$RESPONSE" | jq -r '.token')
|
||||
|
||||
if [ -z "$NPM_TOKEN" ] || [ "$NPM_TOKEN" = "null" ]; then
|
||||
echo "Failed to get NPM token from response"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "NPM_TOKEN=${NPM_TOKEN}" >> $GITHUB_ENV
|
||||
echo "Got NPM token successfully: ${NPM_TOKEN:0:10}..."
|
||||
|
||||
# Configure npm globally with the auth token
|
||||
echo "//localhost:4873/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
||||
echo "Configured ~/.npmrc with auth token"
|
||||
|
||||
# Create a simple test package
|
||||
mkdir -p /tmp/windmill-test-private-pkg
|
||||
cat > /tmp/windmill-test-private-pkg/package.json << 'PKG_JSON'
|
||||
{
|
||||
"name": "@windmill-test/private-pkg",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js"
|
||||
}
|
||||
PKG_JSON
|
||||
cat > /tmp/windmill-test-private-pkg/index.js << 'PKG_JS'
|
||||
module.exports.greet = (name) => `Hello from private package, ${name}!`;
|
||||
PKG_JS
|
||||
|
||||
# Publish to Verdaccio with auth
|
||||
cd /tmp/windmill-test-private-pkg
|
||||
echo "Publishing package..."
|
||||
npm publish --registry http://localhost:4873
|
||||
echo "Package published successfully"
|
||||
|
||||
# Verify the package requires auth by trying anonymous access (should fail)
|
||||
rm -f ~/.npmrc
|
||||
echo "Testing anonymous access (should fail)..."
|
||||
if npm view @windmill-test/private-pkg --registry http://localhost:4873 2>/dev/null; then
|
||||
echo "ERROR: Package should require authentication but anonymous access worked"
|
||||
exit 1
|
||||
fi
|
||||
echo "Verified: Package requires authentication for @windmill-test/private-pkg"
|
||||
- name: Cache DuckDB FFI module build
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -192,10 +84,9 @@ jobs:
|
||||
RUST_LOG_STYLE: never
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: true
|
||||
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
|
||||
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
|
||||
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
|
||||
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
|
||||
TEST_NPM_REGISTRY: "http://localhost:4873/:_authToken=${{ env.NPM_TOKEN }}"
|
||||
run: |
|
||||
deno --version && bun -v && node --version && go version && python3 --version
|
||||
deno --version && bun -v && go version && python3 --version
|
||||
cd windmill-duckdb-ffi-internal && ./build_dev.sh && cd ..
|
||||
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test --all -- --nocapture
|
||||
DENO_PATH=$(which deno) BUN_PATH=$(which bun) GO_PATH=$(which go) UV_PATH=$(which uv) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private --all -- --nocapture
|
||||
|
||||
4
.github/workflows/claude.yml
vendored
4
.github/workflows/claude.yml
vendored
@@ -75,10 +75,10 @@ jobs:
|
||||
npm install
|
||||
npm run generate-backend-client
|
||||
|
||||
- name: install xmlsec1 and gssapi
|
||||
- name: install xmlsec1
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libxml2-dev libxmlsec1-dev libkrb5-dev libsasl2-dev
|
||||
sudo apt-get install -y libxml2-dev libxmlsec1-dev
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
|
||||
81
.github/workflows/docker-image.yml
vendored
81
.github/workflows/docker-image.yml
vendored
@@ -24,6 +24,11 @@ on:
|
||||
description: "Tag the image"
|
||||
required: true
|
||||
default: "test"
|
||||
nsjail:
|
||||
description: "Build nsjail image (true, false)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
slim:
|
||||
description: "Build slim image (true, false)"
|
||||
required: false
|
||||
@@ -101,7 +106,7 @@ jobs:
|
||||
|
||||
build_ee:
|
||||
runs-on: ubicloud
|
||||
if: (github.event_name != 'workflow_dispatch') || github.event.inputs.ee
|
||||
if: (github.event_name != 'workflow_dispatch') || (github.event.inputs.ee || github.event.inputs.nsjail)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -365,10 +370,67 @@ jobs:
|
||||
# ignore-unchanged: true
|
||||
# only-fixed: true
|
||||
|
||||
build_ee_nsjail:
|
||||
needs: [build_ee]
|
||||
runs-on: ubicloud
|
||||
if: (github.event_name != 'pull_request') && ((github.event_name != 'workflow_dispatch') || (github.event.inputs.ee || github.event.inputs.nsjail))
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v2
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta-ee-public
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-nsjail
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,enable=true,priority=100,prefix=,suffix=,format=short
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update Dockerfile image reference
|
||||
run: |
|
||||
sed -i 's|FROM ghcr.io/windmill-labs/windmill-ee:dev|FROM ghcr.io/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}|' ./docker/DockerfileNsjail
|
||||
cat ./docker/DockerfileNsjail | grep "FROM"
|
||||
|
||||
- name: Build and push publicly ee
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
file: "./docker/DockerfileNsjail"
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=Windmill-Enterprise-License
|
||||
|
||||
publish_ecr_s3:
|
||||
needs: [build_ee_full]
|
||||
needs: [build_ee_nsjail]
|
||||
runs-on: ubicloud-standard-2-arm
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
if: (github.event_name != 'pull_request') && (github.event_name !=
|
||||
'workflow_dispatch')
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
@@ -387,18 +449,23 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
- name: get git hash
|
||||
if: github.event_name != 'pull_request'
|
||||
id: git_hash
|
||||
run: |
|
||||
git_hash=$(git rev-parse --short "$GITHUB_SHA")
|
||||
echo "GIT_HASH=${git_hash:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: shrink/actions-docker-extract@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
id: extract
|
||||
with:
|
||||
image: |-
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-full:${{ steps.version.outputs.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-nsjail:${{ steps.git_hash.outputs.GIT_HASH }}
|
||||
path: "/static_frontend/."
|
||||
|
||||
- uses: reggionick/s3-deploy@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
folder: ${{ steps.extract.outputs.destination }}
|
||||
bucket: windmill-frontend
|
||||
|
||||
4
.github/workflows/git-commands.yaml
vendored
4
.github/workflows/git-commands.yaml
vendored
@@ -68,11 +68,11 @@ jobs:
|
||||
with:
|
||||
workspaces: "./backend -> target"
|
||||
|
||||
- name: Install xmlsec and gssapi build-time deps
|
||||
- name: Install xmlsec build-time deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxml2-dev libssl-dev libkrb5-dev \
|
||||
pkg-config libxml2-dev libssl-dev \
|
||||
xmlsec1 libxmlsec1-dev libxmlsec1-openssl
|
||||
|
||||
- name: Run update-sqlx script
|
||||
|
||||
105
.github/workflows/spawn-ephemeral-backend.yml
vendored
105
.github/workflows/spawn-ephemeral-backend.yml
vendored
@@ -3,8 +3,6 @@ name: Spawn Ephemeral Backend
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -13,42 +11,11 @@ on:
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
determine-commenter:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/spawnbackend')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/spawnbackend'))
|
||||
runs-on: ubicloud-standard-2
|
||||
outputs:
|
||||
commenter: ${{ steps.determine-commenter.outputs.commenter }}
|
||||
steps:
|
||||
- name: Determine commenter
|
||||
id: determine-commenter
|
||||
run: |
|
||||
# Work out who wrote the comment / review
|
||||
if [[ "${{ github.event_name }}" == "issue_comment" || \
|
||||
"${{ github.event_name }}" == "pull_request_review_comment" ]]; then
|
||||
COMMENTER="${{ github.event.comment.user.login }}"
|
||||
elif [[ "${{ github.event_name }}" == "pull_request_review" ]]; then
|
||||
COMMENTER="${{ github.event.review.user.login }}"
|
||||
else
|
||||
COMMENTER="${{ github.event.issue.user.login }}"
|
||||
fi
|
||||
echo "commenter=$COMMENTER" >> $GITHUB_OUTPUT
|
||||
|
||||
check-membership:
|
||||
needs: determine-commenter
|
||||
uses: ./.github/workflows/check-org-membership.yml
|
||||
with:
|
||||
commenter: ${{ needs.determine-commenter.outputs.commenter }}
|
||||
secrets:
|
||||
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
|
||||
spawn-backend:
|
||||
needs: [determine-commenter, check-membership]
|
||||
# Only run on PR comments that contain /spawn-backend, or manual dispatch
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.issue.pull_request && needs.check-membership.outputs.is_member == 'true')
|
||||
(github.event.issue.pull_request && contains(github.event.comment.body, '/spawn-backend'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -69,80 +36,40 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
// Get branch name and format it for Cloudflare Pages
|
||||
// Replace '/' with '-' for the URL
|
||||
const branchName = pr.data.head.ref;
|
||||
const formattedBranch = branchName.replace(/\//g, '-');
|
||||
const cfFrontendUrl = `https://${formattedBranch}.windmill.pages.dev`;
|
||||
|
||||
core.setOutput('commit_hash', pr.data.head.sha);
|
||||
core.setOutput('pr_number', prNumber);
|
||||
core.setOutput('branch_name', branchName);
|
||||
core.setOutput('cf_frontend_url', cfFrontendUrl);
|
||||
|
||||
- name: Check manager URL
|
||||
id: check-manager-url
|
||||
run: |
|
||||
if [ -z "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}" ]; then
|
||||
echo "manager_url_set=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "manager_url_set=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Post error comment if manager not running
|
||||
if: steps.check-manager-url.outputs.manager_url_set == 'false'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
? Number(context.payload.inputs.pr_number)
|
||||
: context.issue.number;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: `❌ Manager URL not set (did you start the ephemeral backend manager?)\n\nThe ephemeral backend manager needs to be running to spawn backends. Please start the manager first.`
|
||||
});
|
||||
|
||||
- name: Fail if manager not running
|
||||
if: steps.check-manager-url.outputs.manager_url_set == 'false'
|
||||
run: |
|
||||
echo "Error: EPHEMERAL_BACKEND_QUEUE_URL secret is not set"
|
||||
exit 1
|
||||
core.setOutput('pr_number', context.issue.number);
|
||||
|
||||
- name: Trigger Windmill flow
|
||||
if: steps.check-manager-url.outputs.manager_url_set == 'true'
|
||||
id: trigger-flow
|
||||
run: |
|
||||
JOB_UUID=$(curl -s -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run/f/f/all/run_ephemeral_backend" \
|
||||
-H "Authorization: Bearer ${{ secrets.WINDMILL_RUN_FLOW_TOKEN }}" \
|
||||
RESPONSE=$(curl -s -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run/f/f/all/run_ephemeral_backend" \
|
||||
-H "Authorization: Bearer ${{ secrets.WINDMILL_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"manager_url": "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}",
|
||||
"commit_hash": "${{ steps.pr-details.outputs.commit_hash }}",
|
||||
"pr_number": ${{ steps.pr-details.outputs.pr_number }},
|
||||
"cf_frontend_url": "${{ steps.pr-details.outputs.cf_frontend_url }}"
|
||||
}' | tr -d '"')
|
||||
"pr_number": ${{ steps.pr-details.outputs.pr_number }}
|
||||
}')
|
||||
|
||||
JOB_UUID=$(echo "$RESPONSE" | jq -r '.id // empty')
|
||||
|
||||
if [ -z "$JOB_UUID" ]; then
|
||||
echo "Failed to get job UUID from response: $RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Job UUID: $JOB_UUID"
|
||||
echo "job_uuid=$JOB_UUID" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Post comment with job link
|
||||
if: steps.check-manager-url.outputs.manager_url_set == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const jobUuid = '${{ steps.trigger-flow.outputs.job_uuid }}';
|
||||
const appUrl = `https://app.windmill.dev/public/windmill-labs/a106bad0256c1dfa7a4f9279c42b1a4b#${jobUuid}`;
|
||||
const prNumber = context.eventName === 'workflow_dispatch'
|
||||
? Number(context.payload.inputs.pr_number)
|
||||
: context.issue.number;
|
||||
const jobUrl = `https://app.windmill.dev/run/${jobUuid}?workspace=windmill-labs`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: `🚀 Spawning new ephemeral backend!\n\n${appUrl}`
|
||||
issue_number: context.issue.number,
|
||||
body: `🚀 Ephemeral backend spawning started!\n\nView job progress: ${jobUrl}`
|
||||
});
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -13,9 +13,3 @@ backend/.minio-data
|
||||
.aider*
|
||||
!.aiderignore
|
||||
rust-client/Cargo.toml
|
||||
|
||||
# Symlinked cache directories (for git worktrees)
|
||||
backend/target
|
||||
frontend/node_modules
|
||||
typescript-client/node_modules
|
||||
frontend/.svelte-kit
|
||||
|
||||
90
CHANGELOG.md
90
CHANGELOG.md
@@ -1,95 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [1.628.1](https://github.com/windmill-labs/windmill/compare/v1.628.0...v1.628.1) (2026-02-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* prevent deadlock in consolidate live index migration ([f39b28a](https://github.com/windmill-labs/windmill/commit/f39b28ac416cfdc2420a58b549ee07479f316493))
|
||||
* use concurrent index ops to prevent deadlock on upgrade ([9967f83](https://github.com/windmill-labs/windmill/commit/9967f835ab0cba04bdad4f72b7df786bd1b02fa0))
|
||||
|
||||
## [1.628.0](https://github.com/windmill-labs/windmill/compare/v1.627.0...v1.628.0) (2026-02-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* kafka trigger kerberos/gssapi support ([#7815](https://github.com/windmill-labs/windmill/issues/7815)) ([795e2be](https://github.com/windmill-labs/windmill/commit/795e2bebe65db9c6f721e7cd24af1446aeb896ab))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* make notify_event trigger functions SECURITY DEFINER ([#7826](https://github.com/windmill-labs/windmill/issues/7826)) ([33fb08c](https://github.com/windmill-labs/windmill/commit/33fb08cf3d08c4a6b86f32b3ae8bf2df8c1adcaa))
|
||||
* prevent schedule pool connection exhaustion ([#7821](https://github.com/windmill-labs/windmill/issues/7821)) ([e655a06](https://github.com/windmill-labs/windmill/commit/e655a065637b288080118661650bc14641dd0c6f))
|
||||
|
||||
## [1.627.0](https://github.com/windmill-labs/windmill/compare/v1.626.0...v1.627.0) (2026-02-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* mssql integrated auth (gssapi) ([#7760](https://github.com/windmill-labs/windmill/issues/7760)) ([afa6e7a](https://github.com/windmill-labs/windmill/commit/afa6e7ab5bb26972acbfe19af41dd3e6ac5df363))
|
||||
* restriction rulesets for workspaces ([#7791](https://github.com/windmill-labs/windmill/issues/7791)) ([a1cd02d](https://github.com/windmill-labs/windmill/commit/a1cd02d7f80c97eb07eda04113f3aae815fada69))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow unauthed private pwsh repo ([#7817](https://github.com/windmill-labs/windmill/issues/7817)) ([476e6fd](https://github.com/windmill-labs/windmill/commit/476e6fd4bd2cdb062fa15a8c4048371ef4b31845))
|
||||
* fix asset grant ([e28c5b1](https://github.com/windmill-labs/windmill/commit/e28c5b18af25710b0ed3a3ffbceb18f3da76cd75))
|
||||
|
||||
## [1.626.0](https://github.com/windmill-labs/windmill/compare/v1.625.0...v1.626.0) (2026-02-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **local-dev:** create Claude skills when doing `wmill init` ([#7699](https://github.com/windmill-labs/windmill/issues/7699)) ([a7ce548](https://github.com/windmill-labs/windmill/commit/a7ce5484b8ec386af59f501c36e5ffc147e1d34a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix DB Manager not working with db resources with 4+ path segments ([#7809](https://github.com/windmill-labs/windmill/issues/7809)) ([3476ef4](https://github.com/windmill-labs/windmill/commit/3476ef4b9c795fb8511a83f2297154a4f55aa829))
|
||||
* fix indexer select performances busiying the db ([c3815c8](https://github.com/windmill-labs/windmill/commit/c3815c8c99d5b7d6b2dfc0e3b59d1ba51022ee39))
|
||||
* **frontend:** dedicated worker broken runnable select ([#7808](https://github.com/windmill-labs/windmill/issues/7808)) ([6f6ff9d](https://github.com/windmill-labs/windmill/commit/6f6ff9d4217e99901562b01eb258c7ccdcb0e3f4))
|
||||
* python client oidc pass session token ([#7799](https://github.com/windmill-labs/windmill/issues/7799)) ([b468603](https://github.com/windmill-labs/windmill/commit/b468603f6bc52961057fbd88539eb379a19efd9d))
|
||||
|
||||
## [1.625.0](https://github.com/windmill-labs/windmill/compare/v1.624.0...v1.625.0) (2026-02-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add filters to Kafka triggers ([#7750](https://github.com/windmill-labs/windmill/issues/7750)) ([3c8daa9](https://github.com/windmill-labs/windmill/commit/3c8daa9a58b5e4a2e8c85a9805a5b194ed75d055))
|
||||
* Assets page exploration UI ([#7784](https://github.com/windmill-labs/windmill/issues/7784)) ([0508425](https://github.com/windmill-labs/windmill/commit/05084254a34da81d227813a5190e3ce3dc0f816e))
|
||||
* cache lockfile results for scripts with same raw_workspace_dependencies ([#7787](https://github.com/windmill-labs/windmill/issues/7787)) ([4098679](https://github.com/windmill-labs/windmill/commit/4098679fd7eca059dfa128a6f8b8e1698a65b632))
|
||||
* column-level asset tracking for ducklake and datatables ([#7774](https://github.com/windmill-labs/windmill/issues/7774)) ([0caa533](https://github.com/windmill-labs/windmill/commit/0caa533fbd70fffec27d86d62e16bb92cf7a612a))
|
||||
* favorite datatable and ducklake tables + asset page nits ([#7795](https://github.com/windmill-labs/windmill/issues/7795)) ([a3d75ba](https://github.com/windmill-labs/windmill/commit/a3d75ba10ae85e5ecb55351555879be7fe0bfcca))
|
||||
* make nsjail available in all standard images (CE) ([#7793](https://github.com/windmill-labs/windmill/issues/7793)) ([149da9b](https://github.com/windmill-labs/windmill/commit/149da9b763e4f5dd93d2905be89b5df81bb61934))
|
||||
* public app rate limiting + fork hub raw apps + raw apps publish to hub button ([#7789](https://github.com/windmill-labs/windmill/issues/7789)) ([63f9d85](https://github.com/windmill-labs/windmill/commit/63f9d85bf6a5dd25977995978a8b0a4d32fee995))
|
||||
* replace LISTEN/NOTIFY with polling-based event system ([#7778](https://github.com/windmill-labs/windmill/issues/7778)) ([e860847](https://github.com/windmill-labs/windmill/commit/e860847073b56be469ba37af5e3a8cb7d30ef7bc))
|
||||
* upgrade bun to v1.3.8 with regression tests ([#7761](https://github.com/windmill-labs/windmill/issues/7761)) ([ef89a51](https://github.com/windmill-labs/windmill/commit/ef89a51f3a1cc1ae562d97b413c78393c0ea92cf))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix forking raw apps and summary setting in deploy drawer ([#7792](https://github.com/windmill-labs/windmill/issues/7792)) ([db56518](https://github.com/windmill-labs/windmill/commit/db56518e4fc53931e3498db06bbefd511c343d23))
|
||||
* handle Date serialization in quickjs flow eval via toJSON ([f151fdc](https://github.com/windmill-labs/windmill/commit/f151fdcf7f91a7b0ac75a133d5193538f4a9b4d8))
|
||||
* make private registries settings password in the instance settings ([727bd21](https://github.com/windmill-labs/windmill/commit/727bd2164059e4d44f2e2f6f70a567e7fac3a921))
|
||||
* persist ws_error_handler_muted for flows in create/update ([#7797](https://github.com/windmill-labs/windmill/issues/7797)) ([d113546](https://github.com/windmill-labs/windmill/commit/d113546169a790997d4842b7cfeb43ec2c90c6ea))
|
||||
|
||||
## [1.624.0](https://github.com/windmill-labs/windmill/compare/v1.623.1...v1.624.0) (2026-02-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* default to quickjs on ce for flow eval ([#7756](https://github.com/windmill-labs/windmill/issues/7756)) ([bdf9447](https://github.com/windmill-labs/windmill/commit/bdf9447e821c6d02198534198a5878849cac23e5))
|
||||
* runtime assets ([#7656](https://github.com/windmill-labs/windmill/issues/7656)) ([635a24f](https://github.com/windmill-labs/windmill/commit/635a24f82cae8e85b584efca115968872723889f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** prevent branch-specific items from being marked for deletion on pull ([#7781](https://github.com/windmill-labs/windmill/issues/7781)) ([701eb4b](https://github.com/windmill-labs/windmill/commit/701eb4bae47a809e6da34c62b8e250ac6379db53))
|
||||
* Fix app multiselect not refreshing result when creating element ([#7766](https://github.com/windmill-labs/windmill/issues/7766)) ([3a719ce](https://github.com/windmill-labs/windmill/commit/3a719cea6b7b099f32054957eb04148c592786ad))
|
||||
* **frontend:** improve runs detail page ([#7694](https://github.com/windmill-labs/windmill/issues/7694)) ([3b5c165](https://github.com/windmill-labs/windmill/commit/3b5c1657c7d41178283d02017914543461565a3a))
|
||||
* Prettier and less invasive toasts ([#7758](https://github.com/windmill-labs/windmill/issues/7758)) ([df51f96](https://github.com/windmill-labs/windmill/commit/df51f9690520db80db2133e2e61002f399c0dfaf))
|
||||
* remove $schema field from Google AI output schema requests ([#7765](https://github.com/windmill-labs/windmill/issues/7765)) ([18d85f1](https://github.com/windmill-labs/windmill/commit/18d85f14127e50673ccb460bfa9ebe80730df68e))
|
||||
|
||||
## [1.623.1](https://github.com/windmill-labs/windmill/compare/v1.623.0...v1.623.1) (2026-02-01)
|
||||
|
||||
|
||||
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,26 +1,6 @@
|
||||
ARG DEBIAN_IMAGE=debian:bookworm-slim
|
||||
ARG RUST_IMAGE=rust:1.90-slim-bookworm
|
||||
|
||||
FROM debian:bookworm-slim AS nsjail
|
||||
|
||||
WORKDIR /nsjail
|
||||
|
||||
RUN apt-get -y update \
|
||||
&& apt-get install -y \
|
||||
bison=2:3.8.* \
|
||||
flex=2.6.* \
|
||||
g++=4:12.2.* \
|
||||
gcc=4:12.2.* \
|
||||
git=1:2.39.* \
|
||||
libprotobuf-dev=3.21.* \
|
||||
libnl-route-3-dev=3.7.* \
|
||||
make=4.3-4.1 \
|
||||
pkg-config=1.8.* \
|
||||
protobuf-compiler=3.21.*
|
||||
|
||||
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
|
||||
RUN make
|
||||
|
||||
FROM ${RUST_IMAGE} AS rust_base
|
||||
|
||||
RUN apt-get update && apt-get install -y git libssl-dev pkg-config npm
|
||||
@@ -97,7 +77,7 @@ ARG features=""
|
||||
|
||||
COPY --from=planner /windmill/recipe.json recipe.json
|
||||
|
||||
RUN apt-get update && apt-get install -y libxml2-dev=2.9.* libxmlsec1-dev=1.2.* libkrb5-dev libsasl2-dev clang=1:14.0-55.* libclang-dev=1:14.0-55.* cmake=3.25.* && \
|
||||
RUN apt-get update && apt-get install -y libxml2-dev=2.9.* libxmlsec1-dev=1.2.* clang=1:14.0-55.* libclang-dev=1:14.0-55.* cmake=3.25.* && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -149,7 +129,7 @@ ENV PATH /usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini libsasl2-modules-gssapi-mit \
|
||||
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 software-properties-common tini \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -254,7 +234,7 @@ COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/t
|
||||
|
||||
COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno
|
||||
|
||||
COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
|
||||
COPY --from=oven/bun:1.2.23 /usr/local/bin/bun /usr/bin/bun
|
||||
|
||||
COPY --from=php:8.3.7-cli /usr/local/bin/php /usr/bin/php
|
||||
COPY --from=composer:2.7.6 /usr/bin/composer /usr/bin/composer
|
||||
@@ -266,11 +246,6 @@ ENV RUSTUP_HOME="/usr/local/rustup"
|
||||
ENV CARGO_HOME="/usr/local/cargo"
|
||||
ENV LD_LIBRARY_PATH="."
|
||||
|
||||
# nsjail runtime deps and binary
|
||||
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
|
||||
|
||||
WORKDIR ${APP}
|
||||
|
||||
RUN ln -s ${APP}/windmill /usr/local/bin/windmill
|
||||
|
||||
241
README.md
241
README.md
@@ -3,10 +3,10 @@
|
||||
</p>
|
||||
|
||||
<p align=center>
|
||||
Open-source developer platform for internal code: APIs, background jobs, workflows and UIs. Self-hostable alternative to Retool, Pipedream, Superblocks and a simplified Temporal with autogenerated UIs 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 Retool, 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 languages: Python, TypeScript, Go, Bash, SQL, GraphQL, PowerShell, Rust, and more.
|
||||
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.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -36,58 +36,75 @@ Scripts are turned into sharable UIs automatically, and can be composed together
|
||||
|
||||
# Windmill - Developer platform for APIs, background jobs, workflows and UIs
|
||||
|
||||
Windmill is fully open-sourced (AGPLv3) and Windmill Labs offers dedicated instances and commercial support and licenses.
|
||||
Windmill is <b>fully open-sourced (AGPLv3)</b> and Windmill Labs offers
|
||||
dedicated instance and commercial support and licenses.
|
||||
|
||||

|
||||
|
||||
https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4
|
||||
https://github.com/windmill-labs/windmill/assets/122811744/0b132cd1-ee67-4505-822f-0c7ee7104252
|
||||
|
||||
- [Windmill - Developer platform for APIs, background jobs, workflows and UIs](#windmill---developer-platform-for-apis-background-jobs-workflows-and-uis)
|
||||
- [Main Concepts](#main-concepts)
|
||||
- [Show me some actual script code](#show-me-some-actual-script-code)
|
||||
- [Local Development](#local-development)
|
||||
- [CLI](#cli)
|
||||
- [Running scripts locally](#running-scripts-locally)
|
||||
- [Stack](#stack)
|
||||
- [Fastest Self-Hostable Workflow Engine](#fastest-self-hostable-workflow-engine)
|
||||
- [Security](#security)
|
||||
- [Sandboxing](#sandboxing)
|
||||
- [Secrets, credentials and sensitive values](#secrets-credentials-and-sensitive-values)
|
||||
- [Performance](#performance)
|
||||
- [Architecture](#architecture)
|
||||
- [How to self-host](#how-to-self-host)
|
||||
- [Docker compose](#docker-compose)
|
||||
- [Kubernetes (Helm charts)](#kubernetes-helm-charts)
|
||||
- [Cloud providers](#cloud-providers)
|
||||
- [Kubernetes (k8s) and Helm charts](#kubernetes-k8s-and-helm-charts)
|
||||
- [Run from binaries](#run-from-binaries)
|
||||
- [OAuth, SSO \& SMTP](#oauth-sso--smtp)
|
||||
- [License](#license)
|
||||
- [Commercial license](#commercial-license)
|
||||
- [Integrations](#integrations)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Run a local dev setup](#run-a-local-dev-setup)
|
||||
- [Frontend only](#frontend-only)
|
||||
- [only Frontend](#only-frontend)
|
||||
- [Backend + Frontend](#backend--frontend)
|
||||
- [Contributors](#contributors)
|
||||
- [Copyright](#copyright)
|
||||
|
||||
## Main Concepts
|
||||
|
||||
1. Define a minimal and generic script in Python, TypeScript, Go or Bash that solves a specific task. The code can be defined in the provided Web IDE or synchronized with your own GitHub repo (e.g. through VS Code extension): [provided Web IDE](https://www.windmill.dev/docs/code_editor) or [synchronized with your own GitHub repo](https://www.windmill.dev/docs/advanced/cli/sync) (e.g. through [VS Code](https://www.windmill.dev/docs/cli_local_dev/vscode-extension) extension):
|
||||
1. Define a minimal and generic script in Python, TypeScript, Go or Bash that
|
||||
solves a specific task. The code can be defined in the
|
||||
[provided Web IDE](https://www.windmill.dev/docs/code_editor) or
|
||||
[synchronized with your own GitHub repo](https://www.windmill.dev/docs/advanced/cli/sync)
|
||||
(e.g. through
|
||||
[VS Code](https://www.windmill.dev/docs/cli_local_dev/vscode-extension)
|
||||
extension):
|
||||
|
||||

|
||||

|
||||
|
||||
2. Your scripts parameters are automatically parsed and [generate a frontend](https://www.windmill.dev/docs/core_concepts/auto_generated_uis).
|
||||
2. Your scripts parameters are automatically parsed and
|
||||
[generate a frontend](https://www.windmill.dev/docs/core_concepts/auto_generated_uis).
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
3. Make it [flow](https://www.windmill.dev/docs/flows/flow_editor)! You can chain your scripts or scripts made by the community shared on [WindmillHub](https://hub.windmill.dev).
|
||||
3. Make it [flow](https://www.windmill.dev/docs/flows/flow_editor)! You can
|
||||
chain your scripts or scripts made by the community shared on
|
||||
[WindmillHub](https://hub.windmill.dev).
|
||||
|
||||

|
||||

|
||||
|
||||
4. Build [complex UIs](https://www.windmill.dev/docs/apps/app_editor) on top of your scripts and flows.
|
||||
4. Build [complex UIs](https://www.windmill.dev/docs/apps/app_editor) on top of
|
||||
your scripts and flows.
|
||||
|
||||

|
||||

|
||||
|
||||
Scripts and flows can be triggered by [schedules](https://www.windmill.dev/docs/core_concepts/scheduling), [webhooks](https://www.windmill.dev/docs/core_concepts/webhooks), [HTTP routes](https://www.windmill.dev/docs/core_concepts/http_routing), [Kafka](https://www.windmill.dev/docs/core_concepts/kafka_triggers), [WebSockets](https://www.windmill.dev/docs/core_concepts/websocket_triggers), [emails](https://www.windmill.dev/docs/core_concepts/email_triggers), and more.
|
||||
Scripts and flows can also be triggered by a
|
||||
[cron schedule](https://www.windmill.dev/docs/core_concepts/scheduling) (e.g.
|
||||
'_/5 _ \* \* \*') or through
|
||||
[webhooks](https://www.windmill.dev/docs/core_concepts/webhooks).
|
||||
|
||||
Build your entire infra on top of Windmill!
|
||||
You can build your entire infra on top of Windmill!
|
||||
|
||||
## Show me some actual script code
|
||||
|
||||
@@ -127,31 +144,43 @@ export async function main(
|
||||
}
|
||||
```
|
||||
|
||||
## Local Development
|
||||
## CLI
|
||||
|
||||
Windmill supports multiple ways to develop locally and sync with your instance:
|
||||
We have a powerful CLI to interact with the windmill platform and sync your
|
||||
scripts from local files, GitHub repos and to run scripts and flows on the
|
||||
instance from local commands. See
|
||||
[more details](https://www.windmill.dev/docs/advanced/cli).
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| **[CLI](https://www.windmill.dev/docs/advanced/cli)** | Sync scripts from local files or GitHub, run scripts/flows from the command line |
|
||||
| **[VS Code Extension](https://www.windmill.dev/docs/cli_local_dev/vscode-extension)** | Edit and test scripts & flows directly from VS Code / Cursor with full IDE support |
|
||||
| **[Git Sync](https://www.windmill.dev/docs/advanced/git_sync)** | Two-way sync between Windmill and your Git repository |
|
||||
| **[Claude Code](https://www.windmill.dev/docs/core_concepts/ai_generation)** | AI-assisted development with Claude for scripts, flows, and apps |
|
||||

|
||||
|
||||
https://github.com/user-attachments/assets/c541c326-e9ae-4602-a09a-1989aaded1e9
|
||||
### Running scripts locally
|
||||
|
||||
You can run scripts locally by passing the right environment variables for the `wmill` client library to fetch resources and variables from your instance. See [local development docs](https://www.windmill.dev/docs/advanced/local_development).
|
||||
You can run your script locally easily, you simply need to pass the right
|
||||
environment variables for the `wmill` client library to fetch resources and
|
||||
variables from your instance if necessary. See more:
|
||||
<https://www.windmill.dev/docs/advanced/local_development>.
|
||||
|
||||
To develop & test locally scripts & flows, we recommend using the Windmill VS
|
||||
Code extension: <https://www.windmill.dev/docs/cli_local_dev/vscode-extension>.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Database**: Postgres (compatible with Aurora, Cloud SQL, Neon, Azure PostgreSQL)
|
||||
- **Backend**: Rust - stateless API servers and workers pulling jobs from a Postgres queue
|
||||
- **Frontend**: Svelte 5
|
||||
- **Sandboxing**: [nsjail](https://github.com/google/nsjail) and PID namespace isolation
|
||||
- **Runtimes**:
|
||||
- TypeScript/JavaScript: Bun (default) and Deno
|
||||
- Python: python3 with uv for dependency management
|
||||
- Go, Bash, PowerShell, PHP, Rust, C#, Java, Ansible
|
||||
- Postgres as the database.
|
||||
- Backend in Rust with the following highly-available and horizontally scalable.
|
||||
Architecture:
|
||||
- Stateless API backend.
|
||||
- Workers that pull jobs from a queue in Postgres (and later, Kafka or Redis.
|
||||
Upvote [#173](#https://github.com/windmill-labs/windmill/issues/173) if
|
||||
interested).
|
||||
- Frontend in Svelte.
|
||||
- Scripts executions are sandboxed using Google's
|
||||
[nsjail](https://github.com/google/nsjail).
|
||||
- Javascript runtime is the
|
||||
[deno_core rust library](https://denolib.gitbook.io/guide/) (which itself uses
|
||||
the [rusty_v8](https://github.com/denoland/rusty_v8) and hence V8 underneath).
|
||||
- TypeScript runtime is Bun and deno.
|
||||
- Python runtime is python3.
|
||||
- Golang runtime is 1.19.1.
|
||||
|
||||
## Fastest Self-Hostable Workflow Engine
|
||||
|
||||
@@ -168,10 +197,19 @@ page.
|
||||
|
||||
## Security
|
||||
|
||||
- **Sandboxing**: [nsjail](https://github.com/google/nsjail) for filesystem/resource isolation, and PID namespace isolation (enabled by default) to prevent jobs from accessing worker process memory
|
||||
- **Secrets**: One encryption key per workspace for credentials stored in Windmill's K/V store. We recommend encrypting the Postgres database as well.
|
||||
### Sandboxing
|
||||
|
||||
See [Security documentation](https://www.windmill.dev/docs/advanced/security_isolation) for details.
|
||||
Windmill can use [nsjail](https://github.com/google/nsjail). It is production
|
||||
multi-tenant grade secure. Do not take our word for it, take
|
||||
[fly.io's one](https://fly.io/blog/sandboxing-and-workload-isolation/).
|
||||
|
||||
### Secrets, credentials and sensitive values
|
||||
|
||||
There is one encryption key per workspace to encrypt the credentials and secrets
|
||||
stored in Windmill's K/V store.
|
||||
|
||||
In addition, we strongly recommend that you encrypt the whole Postgres database.
|
||||
That is what we do at <https://app.windmill.dev>.
|
||||
|
||||
## Performance
|
||||
|
||||
@@ -191,13 +229,19 @@ back to the database is ~50ms. A typical lightweight deno job will take around
|
||||
|
||||
## How to self-host
|
||||
|
||||
For detailed setup options, see [Self-Host documentation](https://www.windmill.dev/docs/advanced/self_host).
|
||||
We only provide docker-compose setup here. For more advanced setups, like
|
||||
compiling from source or using without a postgres super user, see
|
||||
[Self-Host documentation](https://www.windmill.dev/docs/advanced/self_host).
|
||||
|
||||
### Docker compose
|
||||
|
||||
Deploy Windmill with 3 files ([docker-compose.yml](./docker-compose.yml), [Caddyfile](./Caddyfile), [.env](./.env)):
|
||||
Windmill can be deployed using 3 files:
|
||||
([docker-compose.yml](./docker-compose.yml), [Caddyfile](./Caddyfile) and a
|
||||
[.env](./.env)) in a single command.
|
||||
|
||||
```bash
|
||||
Make sure Docker is started, and run:
|
||||
|
||||
```
|
||||
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/docker-compose.yml -o docker-compose.yml
|
||||
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/Caddyfile -o Caddyfile
|
||||
curl https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .env
|
||||
@@ -205,45 +249,86 @@ curl https://raw.githubusercontent.com/windmill-labs/windmill/main/.env -o .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Go to http://localhost - default credentials: `admin@windmill.dev` / `changeme`
|
||||
Go to http://localhost et voilà :)
|
||||
|
||||
**Using an external database**: Set `DATABASE_URL` in `.env` to point to your managed Postgres (AWS RDS, GCP Cloud SQL, Azure, Neon, etc.) and set db replicas to 0.
|
||||
The default super-admin user is: admin@windmill.dev / changeme.
|
||||
|
||||
### Kubernetes (Helm charts)
|
||||
From there, you can follow the setup app and create other users.
|
||||
|
||||
More details in
|
||||
[Self-Host Documention](https://www.windmill.dev/docs/advanced/self_host#docker).
|
||||
|
||||
### Kubernetes (k8s) and Helm charts
|
||||
|
||||
We publish helm charts at:
|
||||
<https://github.com/windmill-labs/windmill-helm-charts>.
|
||||
|
||||
### Run from binaries
|
||||
|
||||
Each release includes the corresponding binaries for x86_64. You can simply
|
||||
download the latest `windmill` binary using the following set of bash commands.
|
||||
|
||||
```bash
|
||||
helm repo add windmill https://windmill-labs.github.io/windmill-helm-charts/
|
||||
helm install windmill-chart windmill/windmill --namespace=windmill --create-namespace
|
||||
BINARY_NAME='windmill-amd64' # or windmill-ee-amd64 for the enterprise edition
|
||||
LATEST_RELEASE=$(curl -L -s -H 'Accept: application/json' https://github.com/windmill-labs/windmill/releases/latest)
|
||||
LATEST_VERSION=$(echo $LATEST_RELEASE | sed -e 's/.*"tag_name":"\([^"]*\)".*/\1/')
|
||||
ARTIFACT_URL="https://github.com/windmill-labs/windmill/releases/download/$LATEST_VERSION/$BINARY_NAME"
|
||||
wget "$ARTIFACT_URL" -O windmill
|
||||
```
|
||||
|
||||
See [windmill-helm-charts](https://github.com/windmill-labs/windmill-helm-charts) for configuration options.
|
||||
|
||||
### Cloud providers
|
||||
|
||||
Windmill works on AWS (EKS/ECS), GCP, Azure, Ubicloud, Fly.io, Render.com, Hetzner, Digital Ocean, and others. Rule of thumb: 1 worker per 1vCPU and 1-2 GB RAM.
|
||||
|
||||
### OAuth, SSO & SMTP
|
||||
|
||||
Configure OAuth and SSO (Google Workspace, Microsoft/Azure, Okta) directly from the superadmin UI. [See documentation](https://www.windmill.dev/docs/misc/setup_oauth).
|
||||
Windmill Community Edition allows to configure the OAuth, SSO (including Google
|
||||
Workspace SSO, Microsoft/Azure and Okta) directly from the UI in the superadmin
|
||||
settings. Do note that there is a limit of 10 SSO users on the community
|
||||
edition.
|
||||
|
||||
### License
|
||||
[See documentation](https://www.windmill.dev/docs/misc/setup_oauth).
|
||||
|
||||
The Community Edition is free to use internally. For commercial redistribution or managed services, contact <sales@windmill.dev>. See [LICENSE](./LICENSE) and [Pricing](https://www.windmill.dev/pricing) for details.
|
||||
### Commercial license
|
||||
|
||||
The "Community Edition" of Windmill available in the docker images hosted under ghcr.io/windmill-labs/windmill and the github binary releases contains the files under the AGPLv3 and Apache 2 sources but also includes proprietary and non-public code and features which are not open source and under the following terms: Windmill Labs, Inc. grants a right to use all the features of the "Community Edition" for free without restrictions other than the limits and quotas set in the software and a right to distribute the community edition as is but not to sell, resell, serve Windmill as a managed service, modify or wrap under any form without an explicit agreement.
|
||||
See the [LICENSE](https://github.com/windmill-labs/windmill/blob/main/LICENSE)
|
||||
file for the full license text.
|
||||
|
||||
The binary compilable from source code in this repository without the "enterprise" feature flag is open-source under the [LICENSE-AGPLv3](https://github.com/windmill-labs/windmill/blob/main/LICENSE-AGPL) License terms and conditions.
|
||||
The "Community Edition" of Windmill available in the docker images hosted under
|
||||
ghcr.io/windmill-labs/windmill and the github binary releases contains the files
|
||||
under the AGPLv3 and Apache 2 sources but also includes proprietary and
|
||||
non-public code and features which are not open source and under the following
|
||||
terms: Windmill Labs, Inc. grants a right to use all the features of the
|
||||
"Community Edition" for free without restrictions other than the limits and
|
||||
quotas set in the software and a right to distribute the community edition as is
|
||||
but not to sell, resell, serve Windmill as a managed service, modify or wrap
|
||||
under any form without an explicit agreement.
|
||||
|
||||
To [re-expose directly any Windmill parts to your users](https://www.windmill.dev/docs/misc/white_labelling) as a feature of your product, with the exception of iframed public Windmill "apps", or to build a feature on top of "Windmill Community Edition" that you sell commercially or embed in a distributable product or binary, you must get a commercial license. Contact us at <sales@windmill.dev> if you have any questions. To do the same from the binary compiled from the source code in this repository without the "enterprise" feature flag, you must comply with the AGPLv3 license terms and conditions or get a commercial license from Windmill Labs, Inc.
|
||||
The binary compilable from source code in this repository without the
|
||||
"enterprise" feature flag is open-source under the
|
||||
[LICENSE-AGPLv3](https://github.com/windmill-labs/windmill/blob/main/LICENSE-AGPL)
|
||||
License terms and conditions.
|
||||
|
||||
To use Windmill "Community Edition" as is internally in your organization, or to use its APIs as is, you do NOT need a commercial license.
|
||||
To
|
||||
[re-expose directly any Windmill parts to your users](https://www.windmill.dev/docs/misc/white_labelling)
|
||||
as a feature of your product, with the exception of iframed public Windmill
|
||||
"apps", or to build a feature on top of "Windmill Community Edition" that you
|
||||
sell commercially or embed in a distributable product or binary, you must get a
|
||||
commercial license. Contact us at <sales@windmill.dev> if you have any
|
||||
questions. To do the same from the binary compiled from the source code in this
|
||||
repository without the "enterprise" feature flag, you must comply with the
|
||||
AGPLv3 license terms and conditions or get a commercial license from Windmill
|
||||
Labs, Inc.
|
||||
|
||||
To use Windmill "Community Edition" as is internally in your organization, or to
|
||||
use its APIs as is, you do NOT need a commercial license.
|
||||
|
||||
### Integrations
|
||||
|
||||
In Windmill, integrations are referred to as [resources and resource types](https://www.windmill.dev/docs/core_concepts/resources_and_types). Each Resource has a Resource Type that defines the schema that the resource
|
||||
In Windmill, integrations are referred to as
|
||||
[resources and resource types](https://www.windmill.dev/docs/core_concepts/resources_and_types).
|
||||
Each Resource has a Resource Type that defines the schema that the resource
|
||||
needs to implement.
|
||||
|
||||
On self-hosted instances, you might want to import all the approved resource types from [WindmillHub](https://hub.windmill.dev). A setup script will prompt you to have it being synced automatically everyday.
|
||||
On self-hosted instances, you might want to import all the approved resource
|
||||
types from [WindmillHub](https://hub.windmill.dev). A setup script will prompt
|
||||
you to have it being synced automatically everyday.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -284,20 +369,30 @@ On self-hosted instances, you might want to import all the approved resource typ
|
||||
|
||||
## Run a local dev setup
|
||||
|
||||
We recommend using [Nix](./frontend/README_DEV.md#nix). See [./frontend/README_DEV.md](./frontend/README_DEV.md) for all options.
|
||||
Using [Nix](./frontend/README_DEV.md#nix) (Recommended).
|
||||
|
||||
### Frontend only
|
||||
See the [./frontend/README_DEV.md](./frontend/README_DEV.md) file for all
|
||||
running options.
|
||||
|
||||
Uses the backend of <https://app.windmill.dev> with local frontend (hot-reload):
|
||||
### only Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run generate-backend-client # or generate-backend-client-mac on Mac
|
||||
npm run dev
|
||||
This will use the backend of <https://app.windmill.dev> but your own frontend
|
||||
with hot-code reloading. Note that you will need to use a username / password
|
||||
login due to CSRF checks using a different auth provider.
|
||||
|
||||
In the `frontend/` directory:
|
||||
|
||||
1. install the dependencies with `npm install` (or `pnpm install` or `yarn`)
|
||||
2. generate the windmill client:
|
||||
|
||||
```
|
||||
npm run generate-backend-client
|
||||
## on mac use
|
||||
npm run generate-backend-client-mac
|
||||
```
|
||||
|
||||
Windmill available at `http://localhost/`
|
||||
3. Run your dev server with `npm run dev`
|
||||
4. Et voilà, windmill should be available at `http://localhost/`
|
||||
|
||||
### Backend + Frontend
|
||||
|
||||
@@ -324,7 +419,7 @@ running options.
|
||||
6. Go to `backend/`:
|
||||
1. `env DATABASE_URL=<YOUR_DATABASE_URL> RUST_LOG=info cargo run`
|
||||
2. You can specify any feature flag you want to enable, for example `cargo run --features python` to enable the python executor.
|
||||
7. Windmill should be available at `http://localhost:3000`
|
||||
7. Et voilà, windmill should be available at `http://localhost:3000`
|
||||
|
||||
## Contributors
|
||||
|
||||
@@ -334,4 +429,4 @@ running options.
|
||||
|
||||
## Copyright
|
||||
|
||||
© 2023-2026 Windmill Labs, Inc.
|
||||
Windmill Labs, Inc 2023
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10,\n $11, $12::text::json, $13, now()\n )",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n value, schema, edited_by, edited_at\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10, $11::text::json, $12, now()\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -14,7 +14,6 @@
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar"
|
||||
@@ -22,5 +21,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6bde827da007b470b9d0acccfc3e00ce6aac650b9138a236f34c614eed753849"
|
||||
"hash": "081dc94a7d0fdaade77cfb593a025d8c48d7eab3dbb30ca0b43fb1ef45d8d8bd"
|
||||
}
|
||||
@@ -152,11 +152,6 @@
|
||||
"ordinal": 29,
|
||||
"name": "success_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "public_app_execution_limit_per_minute",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -194,7 +189,6 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"job"
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now(), $12, $13, $14\n )\n ",
|
||||
"query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now(), $11, $12, $13\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -10,7 +10,6 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
{
|
||||
@@ -34,5 +33,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "aed5439aa6dad950e505f9f8f6914fa5ca21319c501b2822c9bc751ddfc9a0a4"
|
||||
"hash": "1de3e078c108a8a0136fccdf9187cc3500260bac39c7f1dfa05a93628569465b"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now()\n WHERE\n path = $12 AND workspace_id = $13",
|
||||
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now()\n WHERE\n path = $11 AND workspace_id = $12",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,7 +12,6 @@
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
@@ -22,5 +21,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "77ac7257be02fb04c4b3213e2221e6f60621b4b2909d770de744ef5671e12ed9"
|
||||
"hash": "207a0721b6f0b8b6ddd4120343eba524a2bc1e9047bdde5f568af4d993dbb74c"
|
||||
}
|
||||
@@ -16,8 +16,7 @@
|
||||
"app",
|
||||
"script",
|
||||
"flow",
|
||||
"raw_app",
|
||||
"asset"
|
||||
"raw_app"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -152,11 +152,6 @@
|
||||
"ordinal": 29,
|
||||
"name": "success_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "public_app_execution_limit_per_minute",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -194,9 +189,8 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c"
|
||||
"hash": "289919809e16aee33c81951b05a4795de710421bcd3e4c06588e56092677bd05"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns)\n VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING",
|
||||
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, asset_detection_kind, job_id)\n VALUES ($1, $2, $3, $4, $5, $6, 'static', NULL) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -40,16 +40,14 @@
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"job"
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Jsonb"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a9e29764b5b9d94269e2b8aa755c71b61774c8ff8ae218d7a8d6ed0ac0169366"
|
||||
"hash": "31eada57708ac3347d1e0a4fb5fd412a4a0c6046dbe4cf91849def2eea2e4c62"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
26
backend/.sqlx/query-3b6bd7b41f130ce6df62fdecb351a3e01be0726d02d3f863e0ea5c476a8e785e.json
generated
Normal file
26
backend/.sqlx/query-3b6bd7b41f130ce6df62fdecb351a3e01be0726d02d3f863e0ea5c476a8e785e.json
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE kafka_trigger \n SET \n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n script_path = $4,\n path = $5,\n is_flow = $6,\n edited_by = $7,\n email = $8,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $11,\n error_handler_args = $12,\n retry = $13\n WHERE \n workspace_id = $9 AND path = $10\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3b6bd7b41f130ce6df62fdecb351a3e01be0726d02d3f863e0ea5c476a8e785e"
|
||||
}
|
||||
42
backend/.sqlx/query-3c6bed058591ad5438428b8c154ba6ee737e59aa033d833843b54e9f58eef3f2.json
generated
Normal file
42
backend/.sqlx/query-3c6bed058591ad5438428b8c154ba6ee737e59aa033d833843b54e9f58eef3f2.json
generated
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, asset_detection_kind, job_id)\n VALUES ($1, $2, $3, NULL, $4, $5, 'runtime', $6) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3c6bed058591ad5438428b8c154ba6ee737e59aa033d833843b54e9f58eef3f2"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b"
|
||||
}
|
||||
15
backend/.sqlx/query-46395d77f3393a058e25ff4efb0b61c743c58418ba2ae1104bcfc99fae7925e4.json
generated
Normal file
15
backend/.sqlx/query-46395d77f3393a058e25ff4efb0b61c743c58418ba2ae1104bcfc99fae7925e4.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1) AND asset_detection_kind = 'static'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "46395d77f3393a058e25ff4efb0b61c743c58418ba2ae1104bcfc99fae7925e4"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT (large_file_storage IS NOT NULL\n AND large_file_storage != 'null'::jsonb\n AND jsonb_typeof(large_file_storage) = 'object') AS \"has_primary!\"\n FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "has_primary!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "46f6a3665d11ef573e27ab14be8a80c78d7eb735bc9615ebf2668a5715d83349"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', favorite.path\n )) as \"favorite_asset!: _\"\n FROM favorite\n WHERE favorite.workspace_id = $1\n AND favorite.usr = $2\n AND favorite_kind = 'asset'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "favorite_asset!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "4f666058177fed05c25036852f772d3cc5e2a5f947f307597b5a4a50f571c89b"
|
||||
}
|
||||
23
backend/.sqlx/query-551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9.json
generated
Normal file
23
backend/.sqlx/query-551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT fv.id\n FROM flow f\n INNER JOIN flow_version fv ON fv.id = f.versions[array_upper(f.versions, 1)]\n WHERE fv.value->'preprocessor_module'->'value'->>'path' = $1 AND f.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9"
|
||||
}
|
||||
@@ -16,8 +16,7 @@
|
||||
"app",
|
||||
"script",
|
||||
"flow",
|
||||
"raw_app",
|
||||
"asset"
|
||||
"raw_app"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
15
backend/.sqlx/query-6fdab4c131f3126d5020b780ba45927dc778ac6fefdc2f91982f600b7cb9954f.json
generated
Normal file
15
backend/.sqlx/query-6fdab4c131f3126d5020b780ba45927dc778ac6fefdc2f91982f600b7cb9954f.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6fdab4c131f3126d5020b780ba45927dc778ac6fefdc2f91982f600b7cb9954f"
|
||||
}
|
||||
53
backend/.sqlx/query-74a2871aba7e35527dcefb2538b8cf41c35618f7c15ea047c5082f8feb7a8464.json
generated
Normal file
53
backend/.sqlx/query-74a2871aba7e35527dcefb2538b8cf41c35618f7c15ea047c5082f8feb7a8464.json
generated
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind)\n VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_access_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"r",
|
||||
"w",
|
||||
"rw"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "74a2871aba7e35527dcefb2538b8cf41c35618f7c15ea047c5082f8feb7a8464"
|
||||
}
|
||||
15
backend/.sqlx/query-752fc50d3374ee5ccdb49f10c11e50bbffeec1088364a64f0849fb9b25e11ef4.json
generated
Normal file
15
backend/.sqlx/query-752fc50d3374ee5ccdb49f10c11e50bbffeec1088364a64f0849fb9b25e11ef4.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = $2 AND asset_detection_kind = 'static'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "752fc50d3374ee5ccdb49f10c11e50bbffeec1088364a64f0849fb9b25e11ef4"
|
||||
}
|
||||
34
backend/.sqlx/query-76033e76f15cee2aa0394d4ec2ff62130e7e48cb40d3b1534b0d791760b33ec7.json
generated
Normal file
34
backend/.sqlx/query-76033e76f15cee2aa0394d4ec2ff62130e7e48cb40d3b1534b0d791760b33ec7.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type\n ) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n ORDER BY path, kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "76033e76f15cee2aa0394d4ec2ff62130e7e48cb40d3b1534b0d791760b33ec7"
|
||||
}
|
||||
34
backend/.sqlx/query-7d73a51d5a83b11c1c4088678dd22ef369e9e26fafb75b351abfdef2b8a7a3e4.json
generated
Normal file
34
backend/.sqlx/query-7d73a51d5a83b11c1c4088678dd22ef369e9e26fafb75b351abfdef2b8a7a3e4.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type\n ) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n AND asset_detection_kind = 'static'\n ORDER BY path, kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "7d73a51d5a83b11c1c4088678dd22ef369e9e26fafb75b351abfdef2b8a7a3e4"
|
||||
}
|
||||
26
backend/.sqlx/query-8ff8b53147d857db7829889ccbb59d2ca0492704a0b4a3cc8d1f4f733e0c794b.json
generated
Normal file
26
backend/.sqlx/query-8ff8b53147d857db7829889ccbb59d2ca0492704a0b4a3cc8d1f4f733e0c794b.json
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3 AND asset_detection_kind = 'static'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8ff8b53147d857db7829889ccbb59d2ca0492704a0b4a3cc8d1f4f733e0c794b"
|
||||
}
|
||||
15
backend/.sqlx/query-97101fae34227628efbcd72457a83a683745a6e2fb120329deba5117cc523bf2.json
generated
Normal file
15
backend/.sqlx/query-97101fae34227628efbcd72457a83a683745a6e2fb120329deba5117cc523bf2.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'flow' AND usage_path = $2 AND asset_detection_kind = 'static'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "97101fae34227628efbcd72457a83a683745a6e2fb120329deba5117cc523bf2"
|
||||
}
|
||||
37
backend/.sqlx/query-a14d43c1baebcdf7e8cebcb7c752f78a2212fe55d4247c2321b37d182d2a4386.json
generated
Normal file
37
backend/.sqlx/query-a14d43c1baebcdf7e8cebcb7c752f78a2212fe55d4247c2321b37d182d2a4386.json
generated
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM asset\n WHERE id IN (\n SELECT id FROM (\n SELECT a.id, ROW_NUMBER() OVER (\n PARTITION BY a.workspace_id, a.path, a.kind\n ORDER BY a.created_at DESC\n ) as rn,\n limits.max_n\n FROM asset a\n INNER JOIN (\n SELECT * FROM UNNEST(\n $1::varchar[], \n $2::varchar[], \n $3::asset_kind[],\n $4::int[]\n ) AS t(workspace_id, path, kind, max_n)\n ) limits\n ON a.workspace_id = limits.workspace_id \n AND a.path = limits.path \n AND a.kind = limits.kind\n WHERE a.asset_detection_kind = 'runtime'\n ) ranked\n WHERE rn > max_n\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"VarcharArray",
|
||||
"VarcharArray",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind[]",
|
||||
"kind": {
|
||||
"Array": {
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a14d43c1baebcdf7e8cebcb7c752f78a2212fe55d4247c2321b37d182d2a4386"
|
||||
}
|
||||
22
backend/.sqlx/query-a21337dac33ef7a1aff1c6797a3ca47fed231f542d5bd8725d6c86b8dd1a2535.json
generated
Normal file
22
backend/.sqlx/query-a21337dac33ef7a1aff1c6797a3ca47fed231f542d5bd8725d6c86b8dd1a2535.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', asset.path,\n 'kind', asset.kind,\n 'usages', ARRAY_AGG(DISTINCT jsonb_build_object(\n 'path', asset.usage_path,\n 'kind', asset.usage_kind,\n 'access_type', asset.usage_access_type,\n 'detection_kinds', (\n SELECT ARRAY_AGG(DISTINCT a2.asset_detection_kind)\n FROM asset a2\n WHERE a2.workspace_id = asset.workspace_id\n AND a2.path = asset.path\n AND a2.kind = asset.kind\n AND a2.usage_path = asset.usage_path\n AND a2.usage_kind = asset.usage_kind\n )\n )),\n 'metadata', (CASE\n WHEN asset.kind = 'resource' THEN\n jsonb_build_object('resource_type', resource.resource_type)\n ELSE\n NULL\n END\n )\n )) as \"list!: _\"\n FROM asset\n LEFT JOIN resource ON asset.kind = 'resource'\n AND array_to_string((string_to_array(asset.path, '/'))[1:3], '/') = resource.path -- With specific table, asset path can be e.g u/diego/pg_db/table_name\n AND resource.workspace_id = $1\n WHERE asset.workspace_id = $1\n AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)\n AND (asset.usage_kind <> 'flow' OR asset.usage_path = ANY(SELECT path FROM flow WHERE workspace_id = $1))\n AND (asset.usage_kind <> 'script' OR asset.usage_path = ANY(SELECT path FROM script WHERE workspace_id = $1))\n GROUP BY asset.path, asset.kind, resource.resource_type\n ORDER BY asset.path, asset.kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a21337dac33ef7a1aff1c6797a3ca47fed231f542d5bd8725d6c86b8dd1a2535"
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type,\n 'columns', columns\n )) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n ORDER BY path, kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"job"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a750630d79166f5b6d5be0e049c36135212666ce74dbf290d701b6402b800f13"
|
||||
}
|
||||
22
backend/.sqlx/query-c94cd50ff1233025b170efee6489e6637676a8b0435a4612a7efbfff6ea2543d.json
generated
Normal file
22
backend/.sqlx/query-c94cd50ff1233025b170efee6489e6637676a8b0435a4612a7efbfff6ea2543d.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', asset.path,\n 'kind', asset.kind,\n 'usages', ARRAY_AGG(jsonb_build_object(\n 'path', asset.usage_path,\n 'kind', asset.usage_kind,\n 'access_type', asset.usage_access_type\n )),\n 'metadata', (CASE\n WHEN asset.kind = 'resource' THEN\n jsonb_build_object('resource_type', resource.resource_type)\n ELSE\n NULL\n END\n )\n )) as \"list!: _\"\n FROM asset\n LEFT JOIN resource ON asset.kind = 'resource'\n AND array_to_string((string_to_array(asset.path, '/'))[1:3], '/') = resource.path -- With specific table, asset path can be e.g u/diego/pg_db/table_name\n AND resource.workspace_id = $1\n WHERE asset.workspace_id = $1\n AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)\n AND (asset.usage_kind <> 'flow' OR asset.usage_path = ANY(SELECT path FROM flow WHERE workspace_id = $1))\n AND (asset.usage_kind <> 'script' OR asset.usage_path = ANY(SELECT path FROM script WHERE workspace_id = $1))\n GROUP BY asset.path, asset.kind, resource.resource_type\n ORDER BY asset.path, asset.kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c94cd50ff1233025b170efee6489e6637676a8b0435a4612a7efbfff6ea2543d"
|
||||
}
|
||||
37
backend/.sqlx/query-df290155c071e823a1d60573a3cba932d070ed730a37450cd30ba9b90205ed3b.json
generated
Normal file
37
backend/.sqlx/query-df290155c071e823a1d60573a3cba932d070ed730a37450cd30ba9b90205ed3b.json
generated
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(DISTINCT asset.job_id)::bigint as \"count!\"\n FROM asset\n WHERE asset.workspace_id = $1\n AND asset.path = $2\n AND asset.kind = $3\n AND asset.asset_detection_kind = 'runtime'\n AND asset.job_id IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "df290155c071e823a1d60573a3cba932d070ed730a37450cd30ba9b90205ed3b"
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n filters = $4,\n script_path = $5,\n path = $6,\n is_flow = $7,\n edited_by = $8,\n email = $9,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $12,\n error_handler_args = $13,\n retry = $14\n WHERE\n workspace_id = $10 AND path = $11\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e2921e44c70cf6c76c55177f2b56985e84c59ecb3e1a13fcf27d5f7ae5f8d84c"
|
||||
}
|
||||
63
backend/.sqlx/query-fb6656bc8fbd2e1b2c59f9ecbab55e5cdccdd7c99af0b61946694c6e03e7f636.json
generated
Normal file
63
backend/.sqlx/query-fb6656bc8fbd2e1b2c59f9ecbab55e5cdccdd7c99af0b61946694c6e03e7f636.json
generated
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT\n v2_job.id,\n v2_job.created_at,\n v2_job.created_by,\n v2_job.runnable_path,\n CASE\n WHEN v2_job_completed.id IS NOT NULL THEN v2_job_completed.status::text\n ELSE NULL\n END as status\n FROM asset\n INNER JOIN v2_job ON asset.job_id = v2_job.id\n LEFT JOIN v2_job_completed ON v2_job.id = v2_job_completed.id\n WHERE asset.workspace_id = $1\n AND asset.path = $2\n AND asset.kind = $3\n AND asset.asset_detection_kind = 'runtime'\n AND asset.job_id IS NOT NULL\n ORDER BY v2_job.created_at DESC\n LIMIT $4 OFFSET $5",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fb6656bc8fbd2e1b2c59f9ecbab55e5cdccdd7c99af0b61946694c6e03e7f636"
|
||||
}
|
||||
15
backend/.sqlx/query-ff64f77d46fe8e1a07f26634df040ec5ced23cbb4390cd851c3bd7013dfd5758.json
generated
Normal file
15
backend/.sqlx/query-ff64f77d46fe8e1a07f26634df040ec5ced23cbb4390cd851c3bd7013dfd5758.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'flow' AND usage_path = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ff64f77d46fe8e1a07f26634df040ec5ced23cbb4390cd851c3bd7013dfd5758"
|
||||
}
|
||||
411
backend/Cargo.lock
generated
411
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.628.1"
|
||||
version = "1.623.1"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -35,7 +35,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.628.1"
|
||||
version = "1.623.1"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -71,7 +71,6 @@ deno_core = ["windmill-worker/deno_core", "windmill-api/deno_core", "dep:deno_co
|
||||
quickjs = ["windmill-worker/quickjs"]
|
||||
deno_core_mac = ["deno_core", "windmill-worker/libffi_mac"]
|
||||
kafka = ["windmill-api/kafka"]
|
||||
kafka-gssapi = ["windmill-api/kafka-gssapi"]
|
||||
nats = ["windmill-api/nats"]
|
||||
otel = ["windmill-common/otel", "windmill-worker/otel"]
|
||||
dind = ["windmill-worker/dind"]
|
||||
@@ -91,7 +90,6 @@ zip = ["windmill-api/zip"]
|
||||
static_frontend = ["windmill-api/static_frontend"]
|
||||
scoped_cache = ["windmill-common/scoped_cache"]
|
||||
test_job_debouncing = []
|
||||
private_registry_test = []
|
||||
# Languages
|
||||
python = ["windmill-worker/python", "windmill-api/python"]
|
||||
rust = ["windmill-worker/rust"]
|
||||
@@ -99,26 +97,22 @@ mysql = ["windmill-worker/mysql"]
|
||||
oracledb = ["windmill-worker/oracledb"]
|
||||
duckdb = ["windmill-worker/duckdb"]
|
||||
mssql = ["windmill-worker/mssql"]
|
||||
mssql-kerberos = ["windmill-worker/mssql-kerberos"] # Linux/Unix integrated auth
|
||||
mssql-winauth = ["windmill-worker/mssql-winauth"] # Windows integrated auth
|
||||
bigquery = ["windmill-worker/bigquery"]
|
||||
php = ["windmill-worker/php"]
|
||||
csharp = ["windmill-worker/csharp"]
|
||||
nu = ["windmill-worker/nu"]
|
||||
java = ["windmill-worker/java"]
|
||||
ruby = ["windmill-worker/ruby"]
|
||||
all_languages = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql-kerberos", "bigquery", "csharp", "nu", "php", "java", "ruby"]
|
||||
all_languages = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql", "bigquery", "csharp", "nu", "php", "java", "ruby"]
|
||||
# For windows we have another set of languages enabled
|
||||
all_languages_windows = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql-winauth", "bigquery", "csharp", "nu", "php", "java"]
|
||||
all_languages_windows = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql", "bigquery", "csharp", "nu", "php", "java"]
|
||||
all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embedding", "parquet", "prometheus", "flow_testing",
|
||||
"openidconnect", "cloud", "jemalloc", "tantivy", "sqlx", "kafka", "kafka-gssapi", "nats", "otel", "dind", "websocket", "http_trigger",
|
||||
"openidconnect", "cloud", "jemalloc", "tantivy", "sqlx", "kafka", "nats", "otel", "dind", "websocket", "http_trigger",
|
||||
"postgres_trigger", "mcp", "mqtt_trigger", "sqs_trigger", "gcp_trigger", "smtp", "stripe",
|
||||
"license", "oauth2", "zip", "static_frontend", "scoped_cache", "agent_worker_server", "bedrock", "native_trigger"]
|
||||
|
||||
[patch.crates-io]
|
||||
object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7" }
|
||||
# Use tiberius main branch for libgssapi 0.8.1 fix (https://github.com/prisma/tiberius/issues/343)
|
||||
tiberius = { git = "https://github.com/prisma/tiberius", rev = "59db57960a14b422fb3a1309aa4aa47880896ff8" }
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
@@ -188,7 +182,6 @@ axum.workspace = true
|
||||
serde.workspace = true
|
||||
windmill-api-client.workspace = true
|
||||
deno_core = { workspace = true, features = ["include_js_files_for_snapshotting", "unsafe_use_unprotected_platform"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -1 +1 @@
|
||||
1f4c304ea02a2bd19f67fc4c4202175d2103fa7a
|
||||
138a4f5f868f3bded5bb7cb77b222b532c07e4af
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE kafka_trigger DROP COLUMN filters;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE kafka_trigger ADD COLUMN filters JSONB[] NOT NULL DEFAULT '{}';
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Remove columns field from asset table
|
||||
ALTER TABLE asset DROP COLUMN columns;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Add columns field to asset table to store column-level access information
|
||||
-- This is a JSONB map of column name to access type (r, w, or rw)
|
||||
ALTER TABLE asset ADD COLUMN columns JSONB;
|
||||
@@ -1,121 +0,0 @@
|
||||
-- Revert to pg_notify based event system
|
||||
|
||||
-- Restore notify_config_change function
|
||||
CREATE OR REPLACE FUNCTION notify_config_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notify_config_change', NEW.name::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_global_setting_change function
|
||||
CREATE OR REPLACE FUNCTION notify_global_setting_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notify_global_setting_change', NEW.name::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_global_setting_delete function
|
||||
CREATE OR REPLACE FUNCTION notify_global_setting_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notify_global_setting_change', OLD.name::text);
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_webhook_change function
|
||||
CREATE OR REPLACE FUNCTION notify_webhook_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notify_webhook_change', NEW.workspace_id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_workspace_envs_change function
|
||||
CREATE OR REPLACE FUNCTION notify_workspace_envs_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notify_workspace_envs_change', NEW.workspace_id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_workspace_premium_change function
|
||||
CREATE OR REPLACE FUNCTION notify_workspace_premium_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notify_workspace_premium_change', NEW.id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_team_plan_status_change function
|
||||
CREATE OR REPLACE FUNCTION notify_team_plan_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notify_workspace_premium_change', NEW.workspace_id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_runnable_version_change function
|
||||
CREATE OR REPLACE FUNCTION notify_runnable_version_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
source_type TEXT;
|
||||
kind TEXT;
|
||||
BEGIN
|
||||
source_type := TG_ARGV[0];
|
||||
|
||||
IF source_type = 'script' THEN
|
||||
kind := NEW.kind;
|
||||
ELSE
|
||||
kind := 'flow';
|
||||
END IF;
|
||||
|
||||
PERFORM pg_notify('notify_runnable_version_change', NEW.workspace_id || ':' || source_type || ':' || NEW.path || ':' || kind);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_http_trigger_change function
|
||||
CREATE OR REPLACE FUNCTION notify_http_trigger_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notify_http_trigger_change', NEW.workspace_id || ':' || NEW.path);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_token_invalidation function
|
||||
CREATE OR REPLACE FUNCTION notify_token_invalidation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.label = 'session' AND OLD.email IS NOT NULL THEN
|
||||
PERFORM pg_notify('notify_token_invalidation', OLD.token);
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Restore notify_workspace_key_change function
|
||||
CREATE OR REPLACE FUNCTION notify_workspace_key_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
PERFORM pg_notify('notify_workspace_key_change', OLD.workspace_id);
|
||||
RETURN OLD;
|
||||
ELSE
|
||||
PERFORM pg_notify('notify_workspace_key_change', NEW.workspace_id);
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the notify_event table
|
||||
DROP TABLE IF EXISTS notify_event;
|
||||
@@ -1,135 +0,0 @@
|
||||
-- Create notify_event table for polling-based event system
|
||||
-- This replaces PostgreSQL LISTEN/NOTIFY with a table-based approach
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notify_event (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
channel TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS notify_event_created_at_idx ON notify_event (created_at);
|
||||
|
||||
-- Drop redundant index if it exists (id is already the PRIMARY KEY)
|
||||
DROP INDEX IF EXISTS notify_event_id_idx;
|
||||
|
||||
-- Update notify_config_change function
|
||||
CREATE OR REPLACE FUNCTION notify_config_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_config_change', NEW.name::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_global_setting_change function
|
||||
CREATE OR REPLACE FUNCTION notify_global_setting_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_global_setting_change', NEW.name::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_global_setting_delete function
|
||||
CREATE OR REPLACE FUNCTION notify_global_setting_delete()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_global_setting_change', OLD.name::text);
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_webhook_change function
|
||||
CREATE OR REPLACE FUNCTION notify_webhook_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_webhook_change', NEW.workspace_id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_workspace_envs_change function
|
||||
CREATE OR REPLACE FUNCTION notify_workspace_envs_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_workspace_envs_change', COALESCE(NEW.workspace_id, OLD.workspace_id));
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_workspace_premium_change function
|
||||
CREATE OR REPLACE FUNCTION notify_workspace_premium_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_workspace_premium_change', NEW.id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_team_plan_status_change function
|
||||
CREATE OR REPLACE FUNCTION notify_team_plan_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_workspace_premium_change', NEW.workspace_id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_runnable_version_change function
|
||||
CREATE OR REPLACE FUNCTION notify_runnable_version_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
source_type TEXT;
|
||||
kind TEXT;
|
||||
BEGIN
|
||||
source_type := TG_ARGV[0];
|
||||
|
||||
IF source_type = 'script' THEN
|
||||
kind := NEW.kind;
|
||||
ELSE
|
||||
kind := 'flow';
|
||||
END IF;
|
||||
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_runnable_version_change', NEW.workspace_id || ':' || source_type || ':' || NEW.path || ':' || kind);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_http_trigger_change function
|
||||
CREATE OR REPLACE FUNCTION notify_http_trigger_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_http_trigger_change', COALESCE(NEW.workspace_id, OLD.workspace_id) || ':' || COALESCE(NEW.path, OLD.path));
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_token_invalidation function
|
||||
CREATE OR REPLACE FUNCTION notify_token_invalidation()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.label = 'session' AND OLD.email IS NOT NULL THEN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_token_invalidation', OLD.token);
|
||||
END IF;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Update notify_workspace_key_change function
|
||||
CREATE OR REPLACE FUNCTION notify_workspace_key_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_workspace_key_change', OLD.workspace_id);
|
||||
RETURN OLD;
|
||||
ELSE
|
||||
INSERT INTO notify_event (channel, payload) VALUES ('notify_workspace_key_change', NEW.workspace_id);
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- NOTE: var_cache_invalidation / resource_cache_invalidation triggers were
|
||||
-- intentionally dropped in migration 20250902085504. We do NOT re-create them
|
||||
-- here to keep this migration scoped to the LISTEN/NOTIFY → polling swap only.
|
||||
@@ -1,5 +0,0 @@
|
||||
DROP TRIGGER IF EXISTS workspace_rate_limit_change_trigger ON workspace_settings;
|
||||
DROP FUNCTION IF EXISTS notify_workspace_rate_limit_change();
|
||||
|
||||
ALTER TABLE workspace_settings
|
||||
DROP COLUMN IF EXISTS public_app_execution_limit_per_minute;
|
||||
@@ -1,19 +0,0 @@
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN IF NOT EXISTS public_app_execution_limit_per_minute INTEGER DEFAULT NULL;
|
||||
|
||||
-- Add trigger function for rate limit changes
|
||||
CREATE OR REPLACE FUNCTION notify_workspace_rate_limit_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload)
|
||||
VALUES ('notify_workspace_rate_limit_change', NEW.workspace_id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger on workspace_settings (drop first if exists)
|
||||
DROP TRIGGER IF EXISTS workspace_rate_limit_change_trigger ON workspace_settings;
|
||||
CREATE TRIGGER workspace_rate_limit_change_trigger
|
||||
AFTER UPDATE OF public_app_execution_limit_per_minute ON workspace_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notify_workspace_rate_limit_change();
|
||||
@@ -1 +0,0 @@
|
||||
-- Add down migration script here
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Add up migration script here
|
||||
|
||||
DO
|
||||
$do$
|
||||
BEGIN
|
||||
ALTER TYPE FAVORITE_KIND ADD VALUE 'asset';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'Couldn''t create FAVORITE_KIND::asset: %', SQLERRM;
|
||||
END
|
||||
$do$;
|
||||
@@ -1,2 +0,0 @@
|
||||
REVOKE ALL ON SEQUENCE asset_id_seq FROM windmill_user;
|
||||
REVOKE ALL ON SEQUENCE asset_id_seq FROM windmill_admin;
|
||||
@@ -1,2 +0,0 @@
|
||||
GRANT ALL ON SEQUENCE asset_id_seq TO windmill_user;
|
||||
GRANT ALL ON SEQUENCE asset_id_seq TO windmill_admin;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- No-op: indexes and windmill_migrations entries are safe to leave in place.
|
||||
-- Rolling back this migration does not require removing the indexes.
|
||||
@@ -1,89 +0,0 @@
|
||||
-- Consolidate live index migrations into a regular SQL migration.
|
||||
-- All statements are idempotent (IF EXISTS / IF NOT EXISTS).
|
||||
|
||||
-- === DROP obsolete indexes ===
|
||||
DROP INDEX IF EXISTS ix_completed_job_workspace_id_created_at;
|
||||
DROP INDEX IF EXISTS ix_completed_job_workspace_id_created_at_new;
|
||||
DROP INDEX IF EXISTS index_completed_job_on_schedule_path;
|
||||
DROP INDEX IF EXISTS concurrency_limit_stats_queue;
|
||||
DROP INDEX IF EXISTS root_job_index;
|
||||
DROP INDEX IF EXISTS index_completed_on_created;
|
||||
DROP INDEX IF EXISTS root_job_index_by_path_2;
|
||||
DROP INDEX IF EXISTS ix_completed_job_workspace_id_created_at_new_2;
|
||||
DROP INDEX IF EXISTS ix_completed_job_workspace_id_started_at_new;
|
||||
DROP INDEX IF EXISTS root_job_index_by_path;
|
||||
DROP INDEX IF EXISTS labeled_jobs_on_jobs;
|
||||
DROP INDEX IF EXISTS ix_job_workspace_id_created_at_new_6;
|
||||
DROP INDEX IF EXISTS ix_job_workspace_id_created_at_new_7;
|
||||
DROP INDEX IF EXISTS queue_sort;
|
||||
DROP INDEX IF EXISTS queue_sort_2;
|
||||
DROP INDEX IF EXISTS log_file_hostname_log_ts_idx;
|
||||
DROP INDEX IF EXISTS ix_completed_job_workspace_id_started_at_new_2;
|
||||
DROP INDEX IF EXISTS ix_job_created_at;
|
||||
DROP INDEX IF EXISTS ix_v2_job_root_by_path;
|
||||
|
||||
-- === CREATE indexes ===
|
||||
CREATE INDEX IF NOT EXISTS ix_job_workspace_id_created_at_new_3
|
||||
ON v2_job (workspace_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_job_workspace_id_created_at_new_8
|
||||
ON v2_job (workspace_id, created_at DESC)
|
||||
WHERE kind IN ('deploymentcallback') AND parent_job IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_job_workspace_id_created_at_new_9
|
||||
ON v2_job (workspace_id, created_at DESC)
|
||||
WHERE kind IN ('dependencies', 'flowdependencies', 'appdependencies') AND parent_job IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_job_workspace_id_created_at_new_5
|
||||
ON v2_job (workspace_id, created_at DESC)
|
||||
WHERE kind IN ('preview', 'flowpreview') AND parent_job IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS labeled_jobs_on_jobs
|
||||
ON v2_job_completed USING GIN ((result -> 'wm_labels'))
|
||||
WHERE result ? 'wm_labels';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_v2_job_labels
|
||||
ON v2_job USING GIN (labels)
|
||||
WHERE labels IS NOT NULL;
|
||||
|
||||
ALTER TABLE v2_job ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_v2_job_workspace_id_created_at
|
||||
ON v2_job (workspace_id, created_at DESC)
|
||||
WHERE kind IN ('script', 'flow', 'singlestepflow') AND parent_job IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS queue_sort_v2
|
||||
ON v2_job_queue (priority DESC NULLS LAST, scheduled_for, tag)
|
||||
WHERE running = false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_audit_timestamps
|
||||
ON audit (timestamp DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_job_completed_completed_at
|
||||
ON v2_job_completed (completed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS alerts_by_workspace
|
||||
ON alerts (workspace_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS v2_job_queue_suspend
|
||||
ON v2_job_queue (workspace_id, suspend)
|
||||
WHERE suspend > 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_recent_login_activities
|
||||
ON audit (timestamp, username)
|
||||
WHERE operation IN ('users.login', 'oauth.login', 'users.token.refresh');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS script_not_archived
|
||||
ON script (workspace_id, path, created_at DESC)
|
||||
WHERE archived = false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_job_workspace_id_completed_at_all
|
||||
ON v2_job_completed (workspace_id, completed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_job_v2_job_root_by_path_2
|
||||
ON v2_job (workspace_id, runnable_path)
|
||||
WHERE parent_job IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_job_root_job_index_by_path_2
|
||||
ON v2_job (workspace_id, runnable_path, created_at DESC)
|
||||
WHERE parent_job IS NULL;
|
||||
@@ -1,12 +0,0 @@
|
||||
ALTER FUNCTION notify_config_change() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_global_setting_change() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_global_setting_delete() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_webhook_change() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_workspace_envs_change() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_workspace_premium_change() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_team_plan_status_change() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_runnable_version_change() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_http_trigger_change() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_token_invalidation() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_workspace_key_change() SECURITY INVOKER;
|
||||
ALTER FUNCTION notify_workspace_rate_limit_change() SECURITY INVOKER;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- Make all notify_event trigger functions SECURITY DEFINER so that
|
||||
-- INSERT INTO notify_event runs as the function owner (typically the
|
||||
-- superuser that created the function) rather than the invoking role.
|
||||
-- This prevents "permission denied for table notify_event" errors when
|
||||
-- windmill_user or windmill_admin fire these triggers.
|
||||
|
||||
ALTER FUNCTION notify_config_change() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_global_setting_change() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_global_setting_delete() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_webhook_change() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_workspace_envs_change() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_workspace_premium_change() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_team_plan_status_change() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_runnable_version_change() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_http_trigger_change() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_token_invalidation() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_workspace_key_change() SECURITY DEFINER;
|
||||
ALTER FUNCTION notify_workspace_rate_limit_change() SECURITY DEFINER;
|
||||
@@ -1 +0,0 @@
|
||||
-- no-op: indexes are safe to leave in place
|
||||
@@ -1,41 +0,0 @@
|
||||
-- v2_job: drop obsolete indexes and create new ones CONCURRENTLY
|
||||
DROP INDEX CONCURRENTLY IF EXISTS root_job_index;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS root_job_index_by_path_2;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS root_job_index_by_path;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS ix_job_workspace_id_created_at_new_6;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS ix_job_workspace_id_created_at_new_7;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS ix_job_created_at;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS ix_v2_job_root_by_path;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_job_workspace_id_created_at_new_3
|
||||
ON v2_job (workspace_id, created_at DESC);
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_job_workspace_id_created_at_new_8
|
||||
ON v2_job (workspace_id, created_at DESC)
|
||||
WHERE kind IN ('deploymentcallback') AND parent_job IS NULL;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_job_workspace_id_created_at_new_9
|
||||
ON v2_job (workspace_id, created_at DESC)
|
||||
WHERE kind IN ('dependencies', 'flowdependencies', 'appdependencies') AND parent_job IS NULL;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_job_workspace_id_created_at_new_5
|
||||
ON v2_job (workspace_id, created_at DESC)
|
||||
WHERE kind IN ('preview', 'flowpreview') AND parent_job IS NULL;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_v2_job_labels
|
||||
ON v2_job USING GIN (labels)
|
||||
WHERE labels IS NOT NULL;
|
||||
|
||||
ALTER TABLE v2_job ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_v2_job_workspace_id_created_at
|
||||
ON v2_job (workspace_id, created_at DESC)
|
||||
WHERE kind IN ('script', 'flow', 'singlestepflow') AND parent_job IS NULL;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_job_v2_job_root_by_path_2
|
||||
ON v2_job (workspace_id, runnable_path)
|
||||
WHERE parent_job IS NULL;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_job_root_job_index_by_path_2
|
||||
ON v2_job (workspace_id, runnable_path, created_at DESC)
|
||||
WHERE parent_job IS NULL;
|
||||
@@ -1 +0,0 @@
|
||||
-- no-op: indexes are safe to leave in place
|
||||
@@ -1,19 +0,0 @@
|
||||
-- v2_job_completed: drop obsolete indexes and create new ones CONCURRENTLY
|
||||
DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_created_at;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_created_at_new;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS index_completed_job_on_schedule_path;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS index_completed_on_created;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_created_at_new_2;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_started_at_new;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_started_at_new_2;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS labeled_jobs_on_jobs;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS labeled_jobs_on_jobs
|
||||
ON v2_job_completed USING GIN ((result -> 'wm_labels'))
|
||||
WHERE result ? 'wm_labels';
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_job_completed_completed_at
|
||||
ON v2_job_completed (completed_at DESC);
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_job_workspace_id_completed_at_all
|
||||
ON v2_job_completed (workspace_id, completed_at DESC);
|
||||
@@ -1 +0,0 @@
|
||||
-- no-op: indexes are safe to leave in place
|
||||
@@ -1,12 +0,0 @@
|
||||
-- v2_job_queue: drop obsolete indexes and create new ones CONCURRENTLY
|
||||
DROP INDEX CONCURRENTLY IF EXISTS concurrency_limit_stats_queue;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS queue_sort;
|
||||
DROP INDEX CONCURRENTLY IF EXISTS queue_sort_2;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS queue_sort_v2
|
||||
ON v2_job_queue (priority DESC NULLS LAST, scheduled_for, tag)
|
||||
WHERE running = false;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS v2_job_queue_suspend
|
||||
ON v2_job_queue (workspace_id, suspend)
|
||||
WHERE suspend > 0;
|
||||
@@ -1 +0,0 @@
|
||||
-- no-op: indexes are safe to leave in place
|
||||
@@ -1,16 +0,0 @@
|
||||
-- audit, alerts, script, log_file: drop obsolete indexes and create new ones CONCURRENTLY
|
||||
DROP INDEX CONCURRENTLY IF EXISTS log_file_hostname_log_ts_idx;
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_audit_timestamps
|
||||
ON audit (timestamp DESC);
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS alerts_by_workspace
|
||||
ON alerts (workspace_id);
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_recent_login_activities
|
||||
ON audit (timestamp, username)
|
||||
WHERE operation IN ('users.login', 'oauth.login', 'users.token.refresh');
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS script_not_archived
|
||||
ON script (workspace_id, path, created_at DESC)
|
||||
WHERE archived = false;
|
||||
@@ -19,12 +19,9 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
// if a db = wmill.datatable() was never used (e.g db.query(...)),
|
||||
// we still want to register the asset as unknown access type
|
||||
if asset_was_used(&assets_finder.assets, (kind, &path)) == false {
|
||||
assets_finder.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
path,
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
assets_finder
|
||||
.assets
|
||||
.push(ParseAssetsResult { kind, access_type: None, path });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,12 +48,8 @@ impl Visitor for AssetsFinder {
|
||||
match removed {
|
||||
Some((kind, path, _)) => {
|
||||
if !asset_was_used(&self.assets, (kind, &path)) {
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
path,
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
self.assets
|
||||
.push(ParseAssetsResult { kind, access_type: None, path });
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
@@ -83,7 +76,6 @@ impl Visitor for AssetsFinder {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -105,7 +97,6 @@ impl Visitor for AssetsFinder {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -261,12 +252,8 @@ impl AssetsFinder {
|
||||
let path = parse_asset_syntax(&value, false)
|
||||
.map(|(_, p)| p)
|
||||
.unwrap_or(&value);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type,
|
||||
columns: None,
|
||||
});
|
||||
self.assets
|
||||
.push(ParseAssetsResult { kind, path: path.to_string(), access_type });
|
||||
}
|
||||
_ => return Err(()),
|
||||
};
|
||||
@@ -279,8 +266,6 @@ struct Arg(usize, &'static str);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -296,8 +281,7 @@ def main():
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/test.csv".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -315,8 +299,7 @@ def main():
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -335,8 +318,7 @@ def main(x: int):
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/friends".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -358,14 +340,12 @@ def main(x: int):
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/analytics".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/friends".to_string(),
|
||||
access_type: Some(RW),
|
||||
columns: Some(BTreeMap::from([("x".to_string(), AssetUsageAccessType::W)])),
|
||||
access_type: Some(RW)
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -392,20 +372,17 @@ def g():
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "another1/customers".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
access_type: Some(W)
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "another2".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main/friends".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -427,14 +404,12 @@ def g():
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "another1".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -454,8 +429,7 @@ def main(x: int):
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/public.friends".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -474,8 +448,7 @@ def main():
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "lake1/analytics.metrics".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -495,8 +468,7 @@ def main(x: int):
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/public.users".to_string(),
|
||||
access_type: Some(RW),
|
||||
columns: None,
|
||||
access_type: Some(RW)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -514,8 +486,7 @@ def main():
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use sqlparser::{
|
||||
ast::{
|
||||
CopyTarget, Expr, ObjectName, ObjectNamePart, SelectItem, TableFactor, TableObject, Value,
|
||||
ValueWithSpan, Visit, Visitor,
|
||||
CopyTarget, Expr, ObjectName, TableFactor, TableObject, Value, ValueWithSpan, Visit,
|
||||
Visitor,
|
||||
},
|
||||
dialect::DuckDbDialect,
|
||||
parser::Parser,
|
||||
@@ -24,12 +24,9 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
|
||||
for (_, (kind, path)) in collector.var_identifiers {
|
||||
if !asset_was_used(&collector.assets, (kind, &path)) {
|
||||
collector.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
access_type: None,
|
||||
path: path,
|
||||
columns: None,
|
||||
});
|
||||
collector
|
||||
.assets
|
||||
.push(ParseAssetsResult { kind, access_type: None, path: path });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +39,7 @@ struct AssetCollector {
|
||||
// e.g set to Read when we are inside a SELECT ... FROM ... statement
|
||||
current_access_type_stack: Vec<AssetUsageAccessType>,
|
||||
// e.g ATTACH 'ducklake://a' AS dl; => { "dl": (Ducklake, "a") }
|
||||
var_identifiers: BTreeMap<String, (AssetKind, String)>,
|
||||
var_identifiers: HashMap<String, (AssetKind, String)>,
|
||||
// e.g USE dl;
|
||||
currently_used_asset: Option<(AssetKind, String)>,
|
||||
}
|
||||
@@ -52,19 +49,15 @@ impl AssetCollector {
|
||||
Self {
|
||||
assets: Vec::new(),
|
||||
current_access_type_stack: Vec::with_capacity(8),
|
||||
var_identifiers: BTreeMap::new(),
|
||||
var_identifiers: HashMap::new(),
|
||||
currently_used_asset: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Detect when we do 'a.b' and 'a' is associated with an asset in var_identifiers
|
||||
// Or when we access 'b' and we did USE a;
|
||||
fn get_associated_asset_from_obj_name(
|
||||
&self,
|
||||
name: &ObjectName,
|
||||
access_type: Option<AssetUsageAccessType>,
|
||||
) -> Option<ParseAssetsResult> {
|
||||
let access_type = access_type.or_else(|| self.current_access_type_stack.last().copied());
|
||||
fn get_associated_asset_from_obj_name(&self, name: &ObjectName) -> Option<ParseAssetsResult> {
|
||||
let access_type = self.current_access_type_stack.last().copied();
|
||||
if let Some((kind, path)) = &self.currently_used_asset {
|
||||
// We don't want to infer that any simple identifier refers to an asset if
|
||||
// we are not in a known R/W context
|
||||
@@ -87,15 +80,8 @@ impl AssetCollector {
|
||||
.map(|id| id.as_ident().map(|id| id.value.clone()))
|
||||
.collect::<Option<Vec<String>>>()?
|
||||
.join(".");
|
||||
|
||||
// For Resource assets, use ?table= query parameter syntax
|
||||
// For Ducklake and DataTable, maintain /table syntax
|
||||
let path = if *kind == AssetKind::Resource {
|
||||
format!("{}?table={}", path, specific_table)
|
||||
} else {
|
||||
format!("{}/{}", path, specific_table)
|
||||
};
|
||||
return Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None });
|
||||
let path = format!("{}/{}", path, specific_table);
|
||||
return Some(ParseAssetsResult { kind: *kind, access_type, path });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,18 +97,11 @@ impl AssetCollector {
|
||||
.map(|id| id.as_ident().map(|id| id.value.clone()))
|
||||
.collect::<Option<Vec<String>>>()?
|
||||
.join(".");
|
||||
|
||||
// For Resource assets, use ?table= query parameter syntax
|
||||
// For Ducklake and DataTable, maintain /table syntax
|
||||
if *kind == AssetKind::Resource {
|
||||
format!("{}?table={}", path, specific_table)
|
||||
} else {
|
||||
format!("{}/{}", path, specific_table)
|
||||
}
|
||||
format!("{}/{}", path, specific_table)
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None })
|
||||
Some(ParseAssetsResult { kind: *kind, access_type, path })
|
||||
}
|
||||
|
||||
fn handle_string_literal(&mut self, s: &str) {
|
||||
@@ -133,7 +112,6 @@ impl AssetCollector {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type: self.current_access_type_stack.last().copied(),
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -148,6 +126,13 @@ impl AssetCollector {
|
||||
if let Some(str_lit) = get_str_lit_from_obj_name(name) {
|
||||
self.handle_string_literal(str_lit);
|
||||
}
|
||||
|
||||
// Writes to tables should be handled directly when visiting the statement
|
||||
if self.current_access_type_stack.last() == Some(&R) {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_obj_name_post(&mut self, name: &ObjectName) {
|
||||
@@ -161,144 +146,20 @@ impl AssetCollector {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_table_with_joins(
|
||||
&mut self,
|
||||
table_with_joins: &sqlparser::ast::TableWithJoins,
|
||||
access_type: Option<AssetUsageAccessType>,
|
||||
) {
|
||||
if let TableFactor::Table { name, args, .. } = &table_with_joins.relation {
|
||||
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
|
||||
return;
|
||||
}
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, access_type) {
|
||||
fn handle_table_with_joins(&mut self, table_with_joins: &sqlparser::ast::TableWithJoins) {
|
||||
if let TableFactor::Table { name, .. } = &table_with_joins.relation {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
}
|
||||
for join in &table_with_joins.joins {
|
||||
if let TableFactor::Table { name, .. } = &join.relation {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, access_type) {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract columns from SELECT items and create individual asset results for each column
|
||||
// Only processes columns that reference known assets to avoid false positives
|
||||
fn extract_column_assets(
|
||||
&mut self,
|
||||
projection: &[SelectItem],
|
||||
from_tables: &[sqlparser::ast::TableWithJoins],
|
||||
) {
|
||||
// Check if this is a single-table SELECT (to avoid ambiguity)
|
||||
let single_table = if from_tables.len() == 1 {
|
||||
if let TableFactor::Table { name, args, .. } = &from_tables[0].relation {
|
||||
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
|
||||
return; // Skip table functions
|
||||
}
|
||||
self.get_associated_asset_from_obj_name(name, Some(R))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Build a map of table aliases/names to assets for multi-table queries
|
||||
let mut table_to_asset: BTreeMap<String, ParseAssetsResult> = BTreeMap::new();
|
||||
for table_with_joins in from_tables {
|
||||
if let TableFactor::Table { name, alias, args, .. } = &table_with_joins.relation {
|
||||
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
|
||||
continue; // Skip table functions
|
||||
}
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(R)) {
|
||||
// Use alias if present, otherwise use the table name
|
||||
let table_key = if let Some(alias) = alias {
|
||||
alias.name.value.clone()
|
||||
} else {
|
||||
// For qualified names like "dl.table1", use just the last part
|
||||
name.0
|
||||
.last()
|
||||
.and_then(|id| id.as_ident())
|
||||
.map(|id| id.value.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
table_to_asset.insert(table_key, asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each SELECT item
|
||||
for item in projection {
|
||||
match item {
|
||||
SelectItem::UnnamedExpr(Expr::Identifier(ident))
|
||||
| SelectItem::ExprWithAlias { expr: Expr::Identifier(ident), .. } => {
|
||||
// Simple column: SELECT a
|
||||
// Only add if we have a single table (unambiguous)
|
||||
if let Some(asset) = &single_table {
|
||||
let mut columns = BTreeMap::new();
|
||||
columns.insert(ident.value.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(columns),
|
||||
});
|
||||
}
|
||||
}
|
||||
SelectItem::UnnamedExpr(Expr::CompoundIdentifier(parts))
|
||||
| SelectItem::ExprWithAlias { expr: Expr::CompoundIdentifier(parts), .. } => {
|
||||
// Qualified column: SELECT table1.a or SELECT x.table1.a
|
||||
if parts.len() >= 2 {
|
||||
let column_name = parts.last().map(|id| id.value.clone());
|
||||
|
||||
if let Some(column_name) = column_name {
|
||||
// Check if the prefix matches a known table
|
||||
let table_prefix = parts.first().map(|id| id.value.clone());
|
||||
|
||||
if let Some(table_prefix) = table_prefix {
|
||||
if let Some(asset) = table_to_asset.get(&table_prefix) {
|
||||
// Found a matching table, add column asset
|
||||
let mut columns = BTreeMap::new();
|
||||
columns.insert(column_name.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(columns),
|
||||
});
|
||||
} else if parts.len() >= 3 {
|
||||
// Could be x.table1.column format or db.schema.table.column
|
||||
// Convert Idents to ObjectNameParts
|
||||
let obj_parts: Vec<ObjectNamePart> = parts[..parts.len() - 1]
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|ident| ObjectNamePart::Identifier(ident))
|
||||
.collect();
|
||||
let obj_name = ObjectName(obj_parts);
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(&obj_name, Some(R))
|
||||
{
|
||||
let mut columns = BTreeMap::new();
|
||||
columns.insert(column_name.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(columns),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Ignore wildcards, expressions, etc.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visitor for AssetCollector {
|
||||
@@ -357,154 +218,51 @@ impl Visitor for AssetCollector {
|
||||
statement: &sqlparser::ast::Statement,
|
||||
) -> std::ops::ControlFlow<Self::Break> {
|
||||
match statement {
|
||||
sqlparser::ast::Statement::Query(q) => {
|
||||
if let Some(select) = q.body.as_select() {
|
||||
// First, handle table references (adds table-level assets)
|
||||
for t in &select.from {
|
||||
self.handle_table_with_joins(t, Some(R));
|
||||
}
|
||||
// Then, extract column-level assets
|
||||
self.extract_column_assets(&select.projection, &select.from);
|
||||
}
|
||||
sqlparser::ast::Statement::Query(_) => {
|
||||
// don't forget pop() in post_visit_statement
|
||||
self.current_access_type_stack.push(R);
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::Insert(insert) => {
|
||||
let access_type = if insert.returning.is_some() { RW } else { W };
|
||||
self.current_access_type_stack.push(access_type);
|
||||
match insert.table {
|
||||
TableObject::TableName(ref name) => {
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(name, Some(access_type))
|
||||
{
|
||||
// Add table-level asset
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: asset.access_type,
|
||||
columns: None,
|
||||
});
|
||||
|
||||
// Extract column information for INSERT with explicit columns (Write access)
|
||||
if !insert.columns.is_empty() {
|
||||
for col in &insert.columns {
|
||||
let columns = BTreeMap::from([(col.value.clone(), W)]);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(W),
|
||||
columns: Some(columns),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract column information from RETURNING clause (Read access)
|
||||
if let Some(returning) = &insert.returning {
|
||||
for item in returning {
|
||||
match item {
|
||||
SelectItem::UnnamedExpr(Expr::Identifier(ident))
|
||||
| SelectItem::ExprWithAlias {
|
||||
expr: Expr::Identifier(ident),
|
||||
..
|
||||
} => {
|
||||
let mut col_map = BTreeMap::new();
|
||||
col_map.insert(ident.value.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(col_map),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Ignore wildcards and complex expressions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::Update { returning, table, from, assignments, .. } => {
|
||||
sqlparser::ast::Statement::Update { returning, table, from, .. } => {
|
||||
if let Some(from_tables) = from {
|
||||
let from_tables = match from_tables {
|
||||
sqlparser::ast::UpdateTableFromKind::AfterSet(tables) => tables,
|
||||
sqlparser::ast::UpdateTableFromKind::BeforeSet(tables) => tables,
|
||||
};
|
||||
self.current_access_type_stack.push(R);
|
||||
for table_with_joins in from_tables {
|
||||
self.handle_table_with_joins(table_with_joins, Some(R));
|
||||
self.handle_table_with_joins(table_with_joins);
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
let access_type = if returning.is_some() { RW } else { W };
|
||||
self.handle_table_with_joins(table, Some(access_type));
|
||||
self.current_access_type_stack.push(access_type);
|
||||
|
||||
// Extract column information from UPDATE SET clauses (Write access)
|
||||
// Only process if it's a single table update
|
||||
if let TableFactor::Table { name, .. } = &table.relation {
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(name, Some(access_type))
|
||||
{
|
||||
// Process each assignment to extract column names
|
||||
for assignment in assignments {
|
||||
// assignment.target is an AssignmentTarget enum
|
||||
// We only handle simple column names (ColumnName variant)
|
||||
if let sqlparser::ast::AssignmentTarget::ColumnName(col_name) =
|
||||
&assignment.target
|
||||
{
|
||||
// For simple column updates, this is typically a single ident
|
||||
if col_name.0.len() == 1 {
|
||||
if let Some(col_ident) =
|
||||
col_name.0.first().and_then(|p| p.as_ident())
|
||||
{
|
||||
let mut col_map = BTreeMap::new();
|
||||
col_map.insert(col_ident.value.clone(), W);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(W),
|
||||
columns: Some(col_map),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.handle_table_with_joins(table);
|
||||
|
||||
// Extract column information from RETURNING clause (Read access)
|
||||
if let Some(returning_items) = returning {
|
||||
for item in returning_items {
|
||||
match item {
|
||||
SelectItem::UnnamedExpr(Expr::Identifier(ident))
|
||||
| SelectItem::ExprWithAlias {
|
||||
expr: Expr::Identifier(ident),
|
||||
..
|
||||
} => {
|
||||
let mut col_map = BTreeMap::new();
|
||||
col_map.insert(ident.value.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(col_map),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Ignore wildcards and complex expressions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::Delete(delete) => {
|
||||
let access_type = if delete.returning.is_some() { RW } else { W };
|
||||
self.current_access_type_stack.push(access_type);
|
||||
for name in &delete.tables {
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(name, Some(access_type))
|
||||
{
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
}
|
||||
@@ -513,22 +271,25 @@ impl Visitor for AssetCollector {
|
||||
sqlparser::ast::FromTable::WithoutKeyword(tables) => tables,
|
||||
};
|
||||
for table_with_joins in tables {
|
||||
self.handle_table_with_joins(table_with_joins, Some(access_type));
|
||||
self.handle_table_with_joins(table_with_joins);
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::CreateTable(create_table) => {
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(&create_table.name, Some(W))
|
||||
{
|
||||
self.current_access_type_stack.push(W);
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(&create_table.name) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::CreateView { name, .. } => {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(W)) {
|
||||
self.current_access_type_stack.push(W);
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::Copy { target: CopyTarget::File { filename }, .. } => {
|
||||
@@ -578,8 +339,14 @@ impl Visitor for AssetCollector {
|
||||
|
||||
fn post_visit_statement(
|
||||
&mut self,
|
||||
_statement: &sqlparser::ast::Statement,
|
||||
statement: &sqlparser::ast::Statement,
|
||||
) -> std::ops::ControlFlow<Self::Break> {
|
||||
match statement {
|
||||
sqlparser::ast::Statement::Query(_) => {
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::ops::ControlFlow::Continue(())
|
||||
}
|
||||
|
||||
@@ -642,20 +409,17 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/a.parquet".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
access_type: Some(R)
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/c.parquet".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
access_type: Some(W)
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "snd/b.parquet".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
access_type: Some(R)
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -674,8 +438,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl".to_string(),
|
||||
access_type: None,
|
||||
columns: None
|
||||
access_type: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -692,8 +455,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/table1".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -711,8 +473,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "my_dt/table1".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
access_type: Some(W)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -743,8 +504,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/table1".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
access_type: Some(W)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -761,8 +521,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main/table1".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
access_type: Some(W)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -784,8 +543,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/friends".to_string(),
|
||||
access_type: Some(RW),
|
||||
columns: None
|
||||
access_type: Some(RW)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -803,8 +561,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main".to_string(),
|
||||
access_type: None,
|
||||
columns: None
|
||||
access_type: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -822,8 +579,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/table1".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
access_type: Some(W)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -841,8 +597,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/table1".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
||||
access_type: Some(W)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -859,9 +614,8 @@ mod tests {
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Resource,
|
||||
path: "u/user/pg_resource?table=table1".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
path: "u/user/pg_resource/table1".to_string(),
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -878,77 +632,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/table1".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_resource_vs_ducklake_syntax() {
|
||||
// Test that Resource uses ?table= while Ducklake uses /table
|
||||
let input_resource = r#"
|
||||
ATTACH 'res://u/user/pg_resource' AS db (TYPE postgres);
|
||||
SELECT * FROM db.users;
|
||||
"#;
|
||||
let s = parse_assets(input_resource).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Resource,
|
||||
path: "u/user/pg_resource?table=users".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
|
||||
let input_ducklake = r#"
|
||||
ATTACH 'ducklake://my_lake' AS dl;
|
||||
SELECT * FROM dl.users;
|
||||
"#;
|
||||
let s = parse_assets(input_ducklake).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_lake/users".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
|
||||
let input_datatable = r#"
|
||||
ATTACH 'datatable://dt1' AS dt;
|
||||
SELECT * FROM dt.users;
|
||||
"#;
|
||||
let s = parse_assets(input_datatable).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt1/users".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_resource_with_long_path() {
|
||||
// Test that Resource works with paths longer than 3 components
|
||||
let input = r#"
|
||||
ATTACH 'res://u/diego/a/b/c/my_postgres_resource' AS db (TYPE postgres);
|
||||
USE db;
|
||||
SELECT * FROM my_table;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
assert_eq!(
|
||||
s.map_err(|e| e.to_string()),
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Resource,
|
||||
path: "u/diego/a/b/c/my_postgres_resource?table=my_table".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
access_type: Some(W)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -966,8 +650,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/sch.table1".to_string(),
|
||||
access_type: Some(RW),
|
||||
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
||||
access_type: Some(RW)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -986,289 +669,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/sch.table1".to_string(),
|
||||
access_type: Some(RW),
|
||||
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
||||
access_type: Some(RW)
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_single_table_column_detection() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT a, b FROM dl.table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have one asset with merged columns
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(R));
|
||||
|
||||
// Check that both columns are present in the merged asset
|
||||
let columns = result[0].columns.as_ref().expect("Should have columns");
|
||||
assert_eq!(columns.len(), 2);
|
||||
assert_eq!(columns.get("a"), Some(&R));
|
||||
assert_eq!(columns.get("b"), Some(&R));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_explicit_table_prefix_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT dl.table1.a, dl.table1.b FROM dl.table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
// Should detect columns with explicit table prefix
|
||||
let result = s.unwrap();
|
||||
// Check we have the table asset
|
||||
|
||||
// Check we have column assets
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_multi_table_no_simple_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT a, b FROM dl.table1, dl.table2;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
// Simple columns (a, b) should NOT be detected with multiple tables
|
||||
// Only table-level assets should be present
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have 2 table assets
|
||||
assert_eq!(result.iter().filter(|a| a.columns.is_none()).count(), 2);
|
||||
|
||||
// Should have NO column assets (ambiguous which table they belong to)
|
||||
assert_eq!(result.iter().filter(|a| a.columns.is_some()).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_multi_table_with_qualified_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl1' AS dl1;
|
||||
ATTACH 'ducklake://my_dl2' AS dl2;
|
||||
SELECT table1.a, table2.b FROM dl1.table1, dl2.table2;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
// Qualified columns should be detected even with multiple tables
|
||||
let result = s.unwrap();
|
||||
|
||||
// Check we have column assets for both tables
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl1/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl2/table2"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_use_with_simple_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
SELECT a, b, c FROM table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should detect columns since it's a single table
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("c"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_wildcard_no_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT * FROM dl.table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Wildcard should NOT create column assets, only table asset
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].columns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_columns_with_alias() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT a AS column_a, b AS column_b FROM dl.table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should detect columns even when aliased
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_columns_with_table_alias() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT t.a, t.b FROM dl.table1 AS t;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should detect columns using the table alias
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_insert_with_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
INSERT INTO dl.table1 (name, age, email) VALUES ('John', 30, 'john@example.com');
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have one asset with merged columns
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(W));
|
||||
|
||||
// Check that all columns are present in the merged asset
|
||||
let columns = result[0].columns.as_ref().expect("Should have columns");
|
||||
assert_eq!(columns.len(), 3);
|
||||
assert_eq!(columns.get("name"), Some(&W));
|
||||
assert_eq!(columns.get("age"), Some(&W));
|
||||
assert_eq!(columns.get("email"), Some(&W));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_insert_without_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
INSERT INTO dl.table1 VALUES ('John', 30);
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have one asset without column information
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(W));
|
||||
assert!(result[0].columns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_update_multiple_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
UPDATE dl.table1 SET name = 'Jane', age = 25, active = true;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have one asset with merged columns
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(W));
|
||||
|
||||
// Check that all columns are present
|
||||
let columns = result[0].columns.as_ref().expect("Should have columns");
|
||||
assert_eq!(columns.len(), 3);
|
||||
assert_eq!(columns.get("name"), Some(&W));
|
||||
assert_eq!(columns.get("age"), Some(&W));
|
||||
assert_eq!(columns.get("active"), Some(&W));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_update_returning() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
UPDATE dl.table1 SET name = 'Jane', age = 26 RETURNING id, name;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have RW access type when RETURNING is used
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(RW));
|
||||
|
||||
// Check that columns are present with correct access types
|
||||
// name and age are written (W), id and name are read (R)
|
||||
// name should be RW (both written and read)
|
||||
let columns = result[0].columns.as_ref().expect("Should have columns");
|
||||
assert_eq!(columns.len(), 3);
|
||||
assert_eq!(columns.get("name"), Some(&RW)); // Written in SET, read in RETURNING
|
||||
assert_eq!(columns.get("age"), Some(&W)); // Only written
|
||||
assert_eq!(columns.get("id"), Some(&R)); // Only read
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,6 @@ impl Visit for AssetsFinder {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -178,12 +177,8 @@ impl Visit for AssetsFinder {
|
||||
if asset_was_used(&self.assets, (kind, path)) {
|
||||
continue;
|
||||
}
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
access_type: None,
|
||||
path: path.clone(),
|
||||
columns: None,
|
||||
});
|
||||
self.assets
|
||||
.push(ParseAssetsResult { kind, access_type: None, path: path.clone() });
|
||||
}
|
||||
|
||||
// Restore state - identifiers declared in this block go out of scope
|
||||
@@ -299,12 +294,8 @@ impl AssetsFinder {
|
||||
let path = parse_asset_syntax(&value, false)
|
||||
.map(|(_, p)| p)
|
||||
.unwrap_or(&value);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type,
|
||||
columns: None,
|
||||
});
|
||||
self.assets
|
||||
.push(ParseAssetsResult { kind, path: path.to_string(), access_type });
|
||||
}
|
||||
_ => return Err(()),
|
||||
}
|
||||
@@ -314,8 +305,6 @@ impl AssetsFinder {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -332,8 +321,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/test.csv".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -352,8 +340,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -373,8 +360,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/friends".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -397,17 +383,12 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/analytics".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/friends".to_string(),
|
||||
access_type: Some(RW),
|
||||
columns: Some(BTreeMap::from([(
|
||||
"name".to_string(),
|
||||
AssetUsageAccessType::W
|
||||
)])),
|
||||
access_type: Some(RW)
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -437,20 +418,17 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "another1/customers".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
access_type: Some(W)
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "another2".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main/friends".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -474,14 +452,12 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "another1".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -502,8 +478,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main/myschema.friends".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -524,8 +499,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/public.users".to_string(),
|
||||
access_type: Some(RW),
|
||||
columns: None,
|
||||
access_type: Some(RW)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -544,8 +518,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt".to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
access_type: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -566,8 +539,7 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/users".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -590,14 +562,12 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/private.users".to_string(),
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
access_type: Some(R)
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/test".to_string(),
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
access_type: Some(W)
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
kind: AssetKind::Resource,
|
||||
path: delegate_to_git_repo_details.resource,
|
||||
access_type: Some(AssetUsageAccessType::R),
|
||||
columns: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,7 +21,6 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
kind: AssetKind::Resource,
|
||||
path: pinned_res,
|
||||
access_type: Some(AssetUsageAccessType::R),
|
||||
columns: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,7 +31,6 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
kind: AssetKind::Resource,
|
||||
path: resource,
|
||||
access_type: Some(AssetUsageAccessType::R),
|
||||
columns: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Serialize, PartialEq, Clone, Copy, Debug)]
|
||||
#[serde(rename_all(serialize = "lowercase"))]
|
||||
@@ -20,14 +19,12 @@ pub enum AssetKind {
|
||||
DataTable,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, PartialEq, Clone)]
|
||||
#[derive(Serialize, Debug, PartialEq)]
|
||||
pub struct ParseAssetsResult {
|
||||
pub kind: AssetKind,
|
||||
pub path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub access_type: Option<AssetUsageAccessType>, // None in case of ambiguity
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub columns: Option<BTreeMap<String, AssetUsageAccessType>>, // Map column name to access type, "*" represents wildcard
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, PartialEq)]
|
||||
@@ -69,8 +66,6 @@ pub fn merge_assets(assets: Vec<ParseAssetsResult>) -> Vec<ParseAssetsResult> {
|
||||
(Some(R), Some(R)) => Some(R),
|
||||
(Some(W), Some(W)) => Some(W),
|
||||
};
|
||||
// merge columns: union the column sets and merge access types per column
|
||||
existing.columns = merge_column_maps(existing.columns.take(), asset.columns);
|
||||
} else {
|
||||
arr.push(asset);
|
||||
}
|
||||
@@ -79,49 +74,18 @@ pub fn merge_assets(assets: Vec<ParseAssetsResult>) -> Vec<ParseAssetsResult> {
|
||||
arr
|
||||
}
|
||||
|
||||
fn merge_column_maps(
|
||||
existing: Option<BTreeMap<String, AssetUsageAccessType>>,
|
||||
new: Option<BTreeMap<String, AssetUsageAccessType>>,
|
||||
) -> Option<BTreeMap<String, AssetUsageAccessType>> {
|
||||
match (existing, new) {
|
||||
(None, None) => None,
|
||||
(Some(map), None) | (None, Some(map)) => Some(map),
|
||||
(Some(mut existing_map), Some(new_map)) => {
|
||||
for (col_name, new_access) in new_map {
|
||||
existing_map
|
||||
.entry(col_name)
|
||||
.and_modify(|existing_access| {
|
||||
*existing_access = merge_access_types(*existing_access, new_access);
|
||||
})
|
||||
.or_insert(new_access);
|
||||
}
|
||||
Some(existing_map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_access_types(a: AssetUsageAccessType, b: AssetUsageAccessType) -> AssetUsageAccessType {
|
||||
match (a, b) {
|
||||
(R, W) | (W, R) => RW,
|
||||
(RW, _) | (_, RW) => RW,
|
||||
(R, R) => R,
|
||||
(W, W) => W,
|
||||
}
|
||||
}
|
||||
|
||||
// Will return false if the user assigned an asset to a variable like:
|
||||
// let sql = wmill.datatable('main')
|
||||
// But never used it. In that case we don't know which table is being used,
|
||||
// but we still want to add the main datatable as an asset with unknown access type.
|
||||
//
|
||||
// This function takes care of the fact that assets can be suffixed (e.g. "main/users" or "u/user/resource?table=table1")
|
||||
// This function takes care of the fact that assets can be suffixed (e.g. "main/users")
|
||||
pub fn asset_was_used(assets: &Vec<ParseAssetsResult>, (kind, path): (AssetKind, &String)) -> bool {
|
||||
assets.iter().any(|a| {
|
||||
let a_path = a.path.as_str();
|
||||
// Check for /table suffix (Ducklake, DataTable) or ?table= suffix (Resource)
|
||||
let has_same_path_base = a_path
|
||||
.strip_prefix(path)
|
||||
.map(|p| p.starts_with('/') || p.starts_with('?'))
|
||||
.map(|p| p.starts_with('/'))
|
||||
.unwrap_or(false);
|
||||
(has_same_path_base || a_path == path) && a.kind == kind
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
edition = "2021"
|
||||
max_width = 100
|
||||
use_small_heuristics = "Default"
|
||||
match_arm_leading_pipes="Preserve"
|
||||
|
||||
@@ -16,7 +16,7 @@ use monitor::{
|
||||
send_current_log_file_to_object_store, send_logs_to_object_store, WORKERS_NAMES,
|
||||
};
|
||||
use rand::Rng;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use sqlx::{postgres::PgListener, Pool, Postgres};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{create_dir_all, DirBuilder},
|
||||
@@ -212,17 +212,11 @@ where
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// Period in seconds between full settings reload (12 hours by default)
|
||||
static ref SETTINGS_RELOAD_PERIOD_SECS: u64 = std::env::var("SETTINGS_RELOAD_PERIOD_SECS")
|
||||
static ref PG_LISTENER_REFRESH_PERIOD_SECS: u64 = std::env::var("PG_LISTENER_REFRESH_PERIOD_SECS")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<u64>().ok())
|
||||
.unwrap_or(3600 * 12);
|
||||
|
||||
// Period in seconds between polling for notify events (10s by default)
|
||||
static ref LISTEN_NEW_EVENTS_INTERVAL_SEC: u64 = std::env::var("LISTEN_NEW_EVENTS_INTERVAL_SEC")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<u64>().ok())
|
||||
.unwrap_or(10);
|
||||
}
|
||||
|
||||
pub fn main() -> anyhow::Result<()> {
|
||||
@@ -1144,24 +1138,8 @@ Windmill Community Edition {GIT_VERSION}
|
||||
let base_internal_url = base_internal_url.to_string();
|
||||
let db = db.clone();
|
||||
let h = tokio::spawn(async move {
|
||||
// Initialize last_event_id to current max to avoid processing old events on startup
|
||||
let mut last_event_id: i64 =
|
||||
match windmill_common::notify_events::get_latest_event_id(&db).await {
|
||||
Ok(id) => {
|
||||
tracing::info!(
|
||||
"Initialized notify event polling with last_event_id: {}",
|
||||
id
|
||||
);
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Could not get latest event id, starting from 0: {e:#}"
|
||||
);
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut last_settings_reload = Instant::now();
|
||||
let mut listener = retry_listen_pg(&db).await;
|
||||
let mut last_listener_refresh = Instant::now();
|
||||
let mut monitor_iteration: u64 = 0;
|
||||
let rd_shift: u8 = rand::rng().random_range(0..200);
|
||||
loop {
|
||||
@@ -1180,36 +1158,349 @@ Windmill Community Edition {GIT_VERSION}
|
||||
tracing::info!("received killpill for monitor job");
|
||||
break;
|
||||
},
|
||||
_ = tokio::time::sleep(Duration::from_secs(*LISTEN_NEW_EVENTS_INTERVAL_SEC)) => {
|
||||
// Poll for new events from notify_event table
|
||||
match windmill_common::notify_events::poll_notify_events(&db, last_event_id).await {
|
||||
Ok(events) => {
|
||||
for event in events {
|
||||
if !*windmill_common::QUIET_LOGS {
|
||||
tracing::info!("Processing notify event: channel={}, payload={}", event.channel, event.payload);
|
||||
notification = listener.try_recv() => {
|
||||
match notification {
|
||||
Ok(n) => {
|
||||
if n.is_none() {
|
||||
tracing::error!("Could not receive notification, attempting to reconnect to pg listener");
|
||||
continue;
|
||||
}
|
||||
let n = n.unwrap();
|
||||
tracing::info!("Received new pg notification: {n:?}");
|
||||
match n.channel() {
|
||||
"notify_config_change" => {
|
||||
match n.payload() {
|
||||
"server" if server_mode => {
|
||||
tracing::error!("Server config change detected but server config is obsolete: {}", n.payload());
|
||||
},
|
||||
a@ _ if worker_mode && a == format!("worker__{}", *WORKER_GROUP) => {
|
||||
tracing::info!("Worker config change detected: {}", n.payload());
|
||||
reload_worker_config(&db, tx.clone(), true).await;
|
||||
},
|
||||
_ => {
|
||||
tracing::debug!("config changed but did not target this server/worker");
|
||||
}
|
||||
}
|
||||
},
|
||||
"notify_webhook_change" => {
|
||||
let workspace_id = n.payload();
|
||||
tracing::info!("Webhook change detected, invalidating webhook cache: {}", workspace_id);
|
||||
windmill_api::webhook_util::WEBHOOK_CACHE.remove(workspace_id);
|
||||
},
|
||||
"notify_workspace_envs_change" => {
|
||||
let workspace_id = n.payload();
|
||||
tracing::info!("Workspace envs change detected, invalidating workspace envs cache: {}", workspace_id);
|
||||
windmill_common::variables::CUSTOM_ENVS_CACHE.remove(workspace_id);
|
||||
},
|
||||
"notify_workspace_key_change" => {
|
||||
let workspace_id = n.payload();
|
||||
tracing::info!("Workspace key change detected, invalidating workspace key cache: {}", workspace_id);
|
||||
windmill_common::variables::WORKSPACE_CRYPT_CACHE.remove(workspace_id);
|
||||
},
|
||||
"notify_workspace_premium_change" => {
|
||||
let workspace_id = n.payload();
|
||||
tracing::info!("Workspace premium change detected, invalidating workspace premium cache: {}", workspace_id);
|
||||
windmill_common::workspaces::TEAM_PLAN_CACHE.remove(workspace_id);
|
||||
},
|
||||
"notify_runnable_version_change" => {
|
||||
let payload = n.payload();
|
||||
tracing::info!("Runnable version change detected: {}", payload);
|
||||
match payload.split(':').collect::<Vec<&str>>().as_slice() {
|
||||
[workspace_id, source_type, path, kind] => {
|
||||
let key = (workspace_id.to_string(), path.to_string());
|
||||
match source_type {
|
||||
&"script" => {
|
||||
windmill_common::DEPLOYED_SCRIPT_HASH_CACHE.remove(&key);
|
||||
match kind {
|
||||
&"preprocessor" => {
|
||||
match sqlx::query_scalar!(
|
||||
"SELECT fv.id
|
||||
FROM flow f
|
||||
INNER JOIN flow_version fv ON fv.id = f.versions[array_upper(f.versions, 1)]
|
||||
WHERE fv.value->'preprocessor_module'->'value'->>'path' = $1 AND f.workspace_id = $2",
|
||||
path,
|
||||
workspace_id
|
||||
).fetch_all(&db).await {
|
||||
Ok(flow_versions) => {
|
||||
tracing::debug!("Workspace preprocessor {} changed, removing runnable format version cache for flow versions {:?}", path, flow_versions);
|
||||
for version in flow_versions {
|
||||
for trigger_kind in TriggerKind::iter() {
|
||||
let key = (windmill_common::triggers::HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()), version, trigger_kind);
|
||||
windmill_common::triggers::RUNNABLE_FORMAT_VERSION_CACHE.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error fetching flow paths: {e:#}");
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
&"flow" => {
|
||||
let dynamic_input_key = windmill_common::jobs::generate_dynamic_input_key(workspace_id, path);
|
||||
windmill_common::DYNAMIC_INPUT_CACHE.remove(&dynamic_input_key);
|
||||
windmill_common::FLOW_VERSION_CACHE.remove(&key);
|
||||
},
|
||||
_ => {
|
||||
tracing::warn!("Unknown runnable version change payload: {}", payload);
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
tracing::warn!("Unknown runnable version change payload: {}", payload);
|
||||
}
|
||||
}
|
||||
},
|
||||
#[cfg(feature = "http_trigger")]
|
||||
"notify_http_trigger_change" => {
|
||||
tracing::info!("HTTP trigger change detected: {}", n.payload());
|
||||
match windmill_api::triggers::http::refresh_routers(&db).await {
|
||||
Ok((true, _)) => {
|
||||
tracing::info!("Refreshed HTTP routers (trigger change)");
|
||||
},
|
||||
Ok((false, _)) => {
|
||||
tracing::warn!("Should have refreshed HTTP routers (trigger change) but did not");
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}");
|
||||
}
|
||||
};
|
||||
},
|
||||
"notify_token_invalidation" => {
|
||||
let token = n.payload();
|
||||
tracing::info!("Token invalidation detected for token: {}...", &token[..token.len().min(8)]);
|
||||
windmill_api::auth::invalidate_token_from_cache(token);
|
||||
},
|
||||
"var_cache_invalidation" => {
|
||||
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(n.payload()) {
|
||||
if let (Some(workspace_id), Some(path)) =
|
||||
(payload.get("workspace_id").and_then(|v| v.as_str()),
|
||||
payload.get("path").and_then(|v| v.as_str())) {
|
||||
tracing::info!("Variable cache invalidation detected: {}:{}", workspace_id, path);
|
||||
windmill_api::var_resource_cache::invalidate_variable_cache(&workspace_id, &path);
|
||||
}
|
||||
}
|
||||
},
|
||||
"resource_cache_invalidation" => {
|
||||
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(n.payload()) {
|
||||
if let (Some(workspace_id), Some(path)) =
|
||||
(payload.get("workspace_id").and_then(|v| v.as_str()),
|
||||
payload.get("path").and_then(|v| v.as_str())) {
|
||||
tracing::info!("Resource cache invalidation detected: {}:{}", workspace_id, path);
|
||||
windmill_api::var_resource_cache::invalidate_resource_cache(&workspace_id, &path);
|
||||
}
|
||||
}
|
||||
},
|
||||
"notify_global_setting_change" => {
|
||||
tracing::info!("Global setting change detected: {}", n.payload());
|
||||
match n.payload() {
|
||||
BASE_URL_SETTING => {
|
||||
if let Err(e) = reload_base_url_setting(&conn).await {
|
||||
tracing::error!(error = %e, "Could not reload base url setting");
|
||||
}
|
||||
},
|
||||
OAUTH_SETTING => {
|
||||
if let Err(e) = reload_base_url_setting(&conn).await {
|
||||
tracing::error!(error = %e, "Could not reload oauth setting");
|
||||
}
|
||||
},
|
||||
CUSTOM_TAGS_SETTING => {
|
||||
if let Err(e) = reload_custom_tags_setting(&db).await {
|
||||
tracing::error!(error = %e, "Could not reload custom tags setting");
|
||||
}
|
||||
},
|
||||
LICENSE_KEY_SETTING => {
|
||||
if let Err(e) = reload_license_key(&db.into()).await {
|
||||
tracing::error!("Failed to reload license key: {e:#}");
|
||||
}
|
||||
},
|
||||
DEFAULT_TAGS_PER_WORKSPACE_SETTING => {
|
||||
if let Err(e) = load_tag_per_workspace_enabled(&db).await {
|
||||
tracing::error!("Error loading default tag per workspace: {e:#}");
|
||||
}
|
||||
},
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING => {
|
||||
if let Err(e) = load_tag_per_workspace_workspaces(&db).await {
|
||||
tracing::error!("Error loading default tag per workspace workspaces: {e:#}");
|
||||
}
|
||||
},
|
||||
SMTP_SETTING => {
|
||||
reload_smtp_config(&db).await;
|
||||
},
|
||||
TEAMS_SETTING => {
|
||||
tracing::info!("Teams setting changed.");
|
||||
},
|
||||
INDEXER_SETTING => {
|
||||
reload_indexer_config(&db).await;
|
||||
},
|
||||
TIMEOUT_WAIT_RESULT_SETTING => {
|
||||
reload_timeout_wait_result_setting(&conn).await
|
||||
},
|
||||
RETENTION_PERIOD_SECS_SETTING => {
|
||||
reload_retention_period_setting(&conn).await
|
||||
},
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING => {
|
||||
reload_delete_logs_periodically_setting(&conn).await
|
||||
},
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING => {
|
||||
reload_job_default_timeout_setting(&conn).await
|
||||
},
|
||||
#[cfg(feature = "parquet")]
|
||||
OBJECT_STORE_CONFIG_SETTING => {
|
||||
if !disable_s3_store {
|
||||
reload_object_store_setting(&db).await;
|
||||
}
|
||||
},
|
||||
SCIM_TOKEN_SETTING => {
|
||||
reload_scim_token_setting(&conn).await
|
||||
},
|
||||
EXTRA_PIP_INDEX_URL_SETTING => {
|
||||
reload_extra_pip_index_url_setting(&conn).await
|
||||
},
|
||||
PIP_INDEX_URL_SETTING => {
|
||||
reload_pip_index_url_setting(&conn).await
|
||||
},
|
||||
INSTANCE_PYTHON_VERSION_SETTING => {
|
||||
reload_instance_python_version_setting(&conn).await
|
||||
},
|
||||
NPM_CONFIG_REGISTRY_SETTING => {
|
||||
reload_npm_config_registry_setting(&conn).await
|
||||
},
|
||||
BUNFIG_INSTALL_SCOPES_SETTING => {
|
||||
reload_bunfig_install_scopes_setting(&conn).await
|
||||
},
|
||||
NUGET_CONFIG_SETTING => {
|
||||
reload_nuget_config_setting(&conn).await
|
||||
},
|
||||
POWERSHELL_REPO_URL_SETTING => {
|
||||
reload_powershell_repo_url_setting(&conn).await
|
||||
},
|
||||
POWERSHELL_REPO_PAT_SETTING => {
|
||||
reload_powershell_repo_pat_setting(&conn).await
|
||||
},
|
||||
MAVEN_REPOS_SETTING => {
|
||||
reload_maven_repos_setting(&conn).await
|
||||
},
|
||||
NO_DEFAULT_MAVEN_SETTING => {
|
||||
reload_no_default_maven_setting(&conn).await
|
||||
},
|
||||
RUBY_REPOS_SETTING => {
|
||||
reload_ruby_repos_setting(&conn).await
|
||||
},
|
||||
HUB_API_SECRET_SETTING => {
|
||||
reload_hub_api_secret_setting(&conn).await
|
||||
},
|
||||
KEEP_JOB_DIR_SETTING => {
|
||||
load_keep_job_dir(&conn).await;
|
||||
},
|
||||
OTEL_TRACING_PROXY_SETTING => {
|
||||
reload_otel_tracing_proxy_setting(&conn).await;
|
||||
if worker_mode {
|
||||
tracing::info!("OTEL tracing proxy setting changed, restarting worker");
|
||||
send_delayed_killpill(&tx, 4, "OTEL tracing proxy setting change").await;
|
||||
}
|
||||
},
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
|
||||
load_require_preexisting_user(&db).await;
|
||||
},
|
||||
EXPOSE_METRICS_SETTING => {
|
||||
tracing::info!("Metrics setting changed, restarting");
|
||||
send_delayed_killpill(&tx, 40, "metrics setting change").await;
|
||||
},
|
||||
EMAIL_DOMAIN_SETTING => {
|
||||
tracing::info!("Email domain setting changed");
|
||||
if server_mode {
|
||||
send_delayed_killpill(&tx, 4, "email domain setting change").await;
|
||||
}
|
||||
},
|
||||
EXPOSE_DEBUG_METRICS_SETTING => {
|
||||
if let Err(e) = load_metrics_debug_enabled(&conn).await {
|
||||
tracing::error!(error = %e, "Could not reload debug metrics setting");
|
||||
}
|
||||
},
|
||||
APP_WORKSPACED_ROUTE_SETTING => {
|
||||
if let Err(e) = reload_app_workspaced_route_setting(&db).await {
|
||||
tracing::error!(error = %e, "Could not reload app workspaced route setting");
|
||||
}
|
||||
},
|
||||
OTEL_SETTING => {
|
||||
tracing::info!("OTEL setting changed, restarting");
|
||||
send_delayed_killpill(&tx, 4, "OTEL setting change").await;
|
||||
},
|
||||
REQUEST_SIZE_LIMIT_SETTING => {
|
||||
if server_mode {
|
||||
tracing::info!("Request limit size change detected, killing server expecting to be restarted");
|
||||
send_delayed_killpill(&tx, 4, "request size limit change").await;
|
||||
}
|
||||
},
|
||||
SAML_METADATA_SETTING => {
|
||||
tracing::info!("SAML metadata change detected, killing server expecting to be restarted");
|
||||
send_delayed_killpill(&tx, 0, "SAML metadata change").await;
|
||||
},
|
||||
HUB_BASE_URL_SETTING => {
|
||||
if let Err(e) = reload_hub_base_url_setting(&conn, server_mode).await {
|
||||
tracing::error!(error = %e, "Could not reload hub base url setting");
|
||||
}
|
||||
},
|
||||
CRITICAL_ERROR_CHANNELS_SETTING => {
|
||||
if let Err(e) = reload_critical_error_channels_setting(&db).await {
|
||||
tracing::error!(error = %e, "Could not reload critical error emails setting");
|
||||
}
|
||||
},
|
||||
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING => {
|
||||
if let Err(e) = reload_critical_alerts_on_db_oversize(&db).await {
|
||||
tracing::error!(error = %e, "Could not reload critical alerts on db oversize setting");
|
||||
}
|
||||
|
||||
},
|
||||
JWT_SECRET_SETTING => {
|
||||
if let Err(e) = reload_jwt_secret_setting(&db).await {
|
||||
tracing::error!(error = %e, "Could not reload jwt secret setting");
|
||||
}
|
||||
},
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING => {
|
||||
tracing::info!("Critical alert UI setting changed");
|
||||
if let Err(e) = reload_critical_alert_mute_ui_setting(&conn).await {
|
||||
tracing::error!(error = %e, "Could not reload critical alert UI setting");
|
||||
}
|
||||
},
|
||||
a @_ => {
|
||||
tracing::info!("Unrecognized Global Setting Change Payload: {:?}", a);
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
tracing::warn!("Unknown notification received");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Could not receive notification, attempting to reconnect listener");
|
||||
let db = db.clone();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = monitor_killpill_rx.recv() => {
|
||||
tracing::info!("received killpill for monitor job");
|
||||
break;
|
||||
},
|
||||
new_listener = async move { retry_listen_pg(&db).await } => {
|
||||
listener = new_listener;
|
||||
continue;
|
||||
}
|
||||
process_notify_event(
|
||||
&event.channel,
|
||||
&event.payload,
|
||||
&db,
|
||||
&conn,
|
||||
&tx,
|
||||
server_mode,
|
||||
worker_mode,
|
||||
#[cfg(feature = "parquet")]
|
||||
disable_s3_store,
|
||||
).await;
|
||||
last_event_id = last_event_id.max(event.id);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error polling notify events: {e:#}");
|
||||
};
|
||||
},
|
||||
_ = tokio::time::sleep(Duration::from_secs(30)) => {
|
||||
if last_listener_refresh.elapsed() > Duration::from_secs(*PG_LISTENER_REFRESH_PERIOD_SECS) {
|
||||
tracing::info!("Refreshing pg listeners, settings and license key after {}s", Duration::from_secs(*PG_LISTENER_REFRESH_PERIOD_SECS).as_secs());
|
||||
if let Err(e) = listener.unlisten_all().await {
|
||||
tracing::error!(error = %e, "Could not unlisten to database");
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic full settings reload
|
||||
if last_settings_reload.elapsed() > Duration::from_secs(*SETTINGS_RELOAD_PERIOD_SECS) {
|
||||
tracing::info!("Reloading settings and license key after {}s", Duration::from_secs(*SETTINGS_RELOAD_PERIOD_SECS).as_secs());
|
||||
listener = retry_listen_pg(&db).await;
|
||||
initial_load(
|
||||
&conn,
|
||||
tx.clone(),
|
||||
@@ -1223,7 +1514,7 @@ Windmill Community Edition {GIT_VERSION}
|
||||
if let Err(err) = reload_license_key(&conn).await {
|
||||
tracing::error!("Failed to reload license key: {err:#}");
|
||||
}
|
||||
last_settings_reload = Instant::now();
|
||||
last_listener_refresh = Instant::now();
|
||||
}
|
||||
|
||||
if server_mode {
|
||||
@@ -1377,297 +1668,50 @@ Windmill Community Edition {GIT_VERSION}
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
/// Process a single notify event from the polling-based event system.
|
||||
/// This replaces the old PgListener notification handling.
|
||||
#[allow(unused_variables)]
|
||||
async fn process_notify_event(
|
||||
channel: &str,
|
||||
payload: &str,
|
||||
db: &Pool<Postgres>,
|
||||
conn: &Connection,
|
||||
tx: &KillpillSender,
|
||||
server_mode: bool,
|
||||
worker_mode: bool,
|
||||
#[cfg(feature = "parquet")] disable_s3_store: bool,
|
||||
) {
|
||||
match channel {
|
||||
"notify_config_change" => {
|
||||
if payload == "server" && server_mode {
|
||||
tracing::error!(
|
||||
"Server config change detected but server config is obsolete: {}",
|
||||
payload
|
||||
);
|
||||
} else if worker_mode && payload == format!("worker__{}", *WORKER_GROUP) {
|
||||
tracing::info!("Worker config change detected: {}", payload);
|
||||
reload_worker_config(db, tx.clone(), true).await;
|
||||
} else {
|
||||
tracing::debug!("config changed but did not target this server/worker");
|
||||
}
|
||||
async fn listen_pg(db: &Pool<Postgres>) -> Option<PgListener> {
|
||||
let mut listener = match PgListener::connect_with(db).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Could not connect to database");
|
||||
return None;
|
||||
}
|
||||
"notify_webhook_change" => {
|
||||
tracing::info!(
|
||||
"Webhook change detected, invalidating webhook cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_api::webhook_util::WEBHOOK_CACHE.remove(payload);
|
||||
}
|
||||
"notify_workspace_envs_change" => {
|
||||
tracing::info!(
|
||||
"Workspace envs change detected, invalidating workspace envs cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_common::variables::CUSTOM_ENVS_CACHE.remove(payload);
|
||||
}
|
||||
"notify_workspace_key_change" => {
|
||||
tracing::info!(
|
||||
"Workspace key change detected, invalidating workspace key cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_common::variables::WORKSPACE_CRYPT_CACHE.remove(payload);
|
||||
}
|
||||
"notify_workspace_premium_change" => {
|
||||
tracing::info!(
|
||||
"Workspace premium change detected, invalidating workspace premium cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_common::workspaces::TEAM_PLAN_CACHE.remove(payload);
|
||||
}
|
||||
"notify_workspace_rate_limit_change" => {
|
||||
tracing::info!(
|
||||
"Workspace rate limit change detected, invalidating rate limit cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE.remove(payload);
|
||||
}
|
||||
"notify_runnable_version_change" => {
|
||||
tracing::info!("Runnable version change detected: {}", payload);
|
||||
match payload.split(':').collect::<Vec<&str>>().as_slice() {
|
||||
[workspace_id, source_type, path, kind] => {
|
||||
let key = (workspace_id.to_string(), path.to_string());
|
||||
match *source_type {
|
||||
"script" => {
|
||||
windmill_common::DEPLOYED_SCRIPT_HASH_CACHE.remove(&key);
|
||||
if *kind == "preprocessor" {
|
||||
match sqlx::query_scalar::<_, i64>(
|
||||
"SELECT fv.id
|
||||
FROM flow f
|
||||
INNER JOIN flow_version fv ON fv.id = f.versions[array_upper(f.versions, 1)]
|
||||
WHERE fv.value->'preprocessor_module'->'value'->>'path' = $1 AND f.workspace_id = $2",
|
||||
)
|
||||
.bind(*path)
|
||||
.bind(*workspace_id)
|
||||
.fetch_all(db).await {
|
||||
Ok(flow_versions) => {
|
||||
tracing::debug!("Workspace preprocessor {} changed, removing runnable format version cache for flow versions {:?}", path, flow_versions);
|
||||
for version in flow_versions {
|
||||
for trigger_kind in TriggerKind::iter() {
|
||||
let key = (windmill_common::triggers::HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()), version, trigger_kind);
|
||||
windmill_common::triggers::RUNNABLE_FORMAT_VERSION_CACHE.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error fetching flow paths: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"flow" => {
|
||||
let dynamic_input_key =
|
||||
windmill_common::jobs::generate_dynamic_input_key(
|
||||
workspace_id,
|
||||
path,
|
||||
);
|
||||
windmill_common::DYNAMIC_INPUT_CACHE.remove(&dynamic_input_key);
|
||||
windmill_common::FLOW_VERSION_CACHE.remove(&key);
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Unknown runnable version change payload: {}", payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Unknown runnable version change payload: {}", payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "http_trigger")]
|
||||
"notify_http_trigger_change" => {
|
||||
tracing::info!("HTTP trigger change detected: {}", payload);
|
||||
match windmill_api::triggers::http::refresh_routers(db).await {
|
||||
Ok((true, _)) => {
|
||||
tracing::info!("Refreshed HTTP routers (trigger change)");
|
||||
}
|
||||
Ok((false, _)) => {
|
||||
tracing::warn!(
|
||||
"Should have refreshed HTTP routers (trigger change) but did not"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}");
|
||||
}
|
||||
};
|
||||
}
|
||||
"notify_token_invalidation" => {
|
||||
tracing::info!(
|
||||
"Token invalidation detected for token: {}...",
|
||||
payload.get(..8).unwrap_or(payload)
|
||||
);
|
||||
windmill_api::auth::invalidate_token_from_cache(payload);
|
||||
}
|
||||
"notify_global_setting_change" => {
|
||||
tracing::info!("Global setting change detected: {}", payload);
|
||||
match payload {
|
||||
BASE_URL_SETTING => {
|
||||
if let Err(e) = reload_base_url_setting(conn).await {
|
||||
tracing::error!(error = %e, "Could not reload base url setting");
|
||||
}
|
||||
}
|
||||
OAUTH_SETTING => {
|
||||
if let Err(e) = reload_base_url_setting(conn).await {
|
||||
tracing::error!(error = %e, "Could not reload oauth setting");
|
||||
}
|
||||
}
|
||||
CUSTOM_TAGS_SETTING => {
|
||||
if let Err(e) = reload_custom_tags_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload custom tags setting");
|
||||
}
|
||||
}
|
||||
LICENSE_KEY_SETTING => {
|
||||
if let Err(e) = reload_license_key(&db.into()).await {
|
||||
tracing::error!("Failed to reload license key: {e:#}");
|
||||
}
|
||||
}
|
||||
DEFAULT_TAGS_PER_WORKSPACE_SETTING => {
|
||||
if let Err(e) = load_tag_per_workspace_enabled(db).await {
|
||||
tracing::error!("Error loading default tag per workspace: {e:#}");
|
||||
}
|
||||
}
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING => {
|
||||
if let Err(e) = load_tag_per_workspace_workspaces(db).await {
|
||||
tracing::error!(
|
||||
"Error loading default tag per workspace workspaces: {e:#}"
|
||||
);
|
||||
}
|
||||
}
|
||||
SMTP_SETTING => {
|
||||
reload_smtp_config(db).await;
|
||||
}
|
||||
TEAMS_SETTING => {
|
||||
tracing::info!("Teams setting changed.");
|
||||
}
|
||||
INDEXER_SETTING => {
|
||||
reload_indexer_config(db).await;
|
||||
}
|
||||
TIMEOUT_WAIT_RESULT_SETTING => reload_timeout_wait_result_setting(conn).await,
|
||||
RETENTION_PERIOD_SECS_SETTING => reload_retention_period_setting(conn).await,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING => {
|
||||
reload_delete_logs_periodically_setting(conn).await
|
||||
}
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING => reload_job_default_timeout_setting(conn).await,
|
||||
#[cfg(feature = "parquet")]
|
||||
OBJECT_STORE_CONFIG_SETTING => {
|
||||
if !disable_s3_store {
|
||||
reload_object_store_setting(db).await;
|
||||
}
|
||||
}
|
||||
SCIM_TOKEN_SETTING => reload_scim_token_setting(conn).await,
|
||||
EXTRA_PIP_INDEX_URL_SETTING => reload_extra_pip_index_url_setting(conn).await,
|
||||
PIP_INDEX_URL_SETTING => reload_pip_index_url_setting(conn).await,
|
||||
INSTANCE_PYTHON_VERSION_SETTING => {
|
||||
reload_instance_python_version_setting(conn).await
|
||||
}
|
||||
NPM_CONFIG_REGISTRY_SETTING => reload_npm_config_registry_setting(conn).await,
|
||||
BUNFIG_INSTALL_SCOPES_SETTING => reload_bunfig_install_scopes_setting(conn).await,
|
||||
NUGET_CONFIG_SETTING => reload_nuget_config_setting(conn).await,
|
||||
POWERSHELL_REPO_URL_SETTING => reload_powershell_repo_url_setting(conn).await,
|
||||
POWERSHELL_REPO_PAT_SETTING => reload_powershell_repo_pat_setting(conn).await,
|
||||
MAVEN_REPOS_SETTING => reload_maven_repos_setting(conn).await,
|
||||
NO_DEFAULT_MAVEN_SETTING => reload_no_default_maven_setting(conn).await,
|
||||
RUBY_REPOS_SETTING => reload_ruby_repos_setting(conn).await,
|
||||
HUB_API_SECRET_SETTING => reload_hub_api_secret_setting(conn).await,
|
||||
KEEP_JOB_DIR_SETTING => {
|
||||
load_keep_job_dir(conn).await;
|
||||
}
|
||||
OTEL_TRACING_PROXY_SETTING => {
|
||||
reload_otel_tracing_proxy_setting(conn).await;
|
||||
if worker_mode {
|
||||
tracing::info!("OTEL tracing proxy setting changed, restarting worker");
|
||||
send_delayed_killpill(tx, 4, "OTEL tracing proxy setting change").await;
|
||||
}
|
||||
}
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
|
||||
load_require_preexisting_user(db).await;
|
||||
}
|
||||
EXPOSE_METRICS_SETTING => {
|
||||
tracing::info!("Metrics setting changed, restarting");
|
||||
send_delayed_killpill(tx, 40, "metrics setting change").await;
|
||||
}
|
||||
EMAIL_DOMAIN_SETTING => {
|
||||
tracing::info!("Email domain setting changed");
|
||||
if server_mode {
|
||||
send_delayed_killpill(tx, 4, "email domain setting change").await;
|
||||
}
|
||||
}
|
||||
EXPOSE_DEBUG_METRICS_SETTING => {
|
||||
if let Err(e) = load_metrics_debug_enabled(conn).await {
|
||||
tracing::error!(error = %e, "Could not reload debug metrics setting");
|
||||
}
|
||||
}
|
||||
APP_WORKSPACED_ROUTE_SETTING => {
|
||||
if let Err(e) = reload_app_workspaced_route_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload app workspaced route setting");
|
||||
}
|
||||
}
|
||||
OTEL_SETTING => {
|
||||
tracing::info!("OTEL setting changed, restarting");
|
||||
send_delayed_killpill(tx, 4, "OTEL setting change").await;
|
||||
}
|
||||
REQUEST_SIZE_LIMIT_SETTING => {
|
||||
if server_mode {
|
||||
tracing::info!("Request limit size change detected, killing server expecting to be restarted");
|
||||
send_delayed_killpill(tx, 4, "request size limit change").await;
|
||||
}
|
||||
}
|
||||
SAML_METADATA_SETTING => {
|
||||
tracing::info!(
|
||||
"SAML metadata change detected, killing server expecting to be restarted"
|
||||
);
|
||||
send_delayed_killpill(tx, 0, "SAML metadata change").await;
|
||||
}
|
||||
HUB_BASE_URL_SETTING => {
|
||||
if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await {
|
||||
tracing::error!(error = %e, "Could not reload hub base url setting");
|
||||
}
|
||||
}
|
||||
CRITICAL_ERROR_CHANNELS_SETTING => {
|
||||
if let Err(e) = reload_critical_error_channels_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload critical error emails setting");
|
||||
}
|
||||
}
|
||||
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING => {
|
||||
if let Err(e) = reload_critical_alerts_on_db_oversize(db).await {
|
||||
tracing::error!(error = %e, "Could not reload critical alerts on db oversize setting");
|
||||
}
|
||||
}
|
||||
JWT_SECRET_SETTING => {
|
||||
if let Err(e) = reload_jwt_secret_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload jwt secret setting");
|
||||
}
|
||||
}
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING => {
|
||||
tracing::info!("Critical alert UI setting changed");
|
||||
if let Err(e) = reload_critical_alert_mute_ui_setting(conn).await {
|
||||
tracing::error!(error = %e, "Could not reload critical alert UI setting");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("Unrecognized Global Setting Change Payload: {:?}", payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Unknown notification channel: {}", channel);
|
||||
};
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut channels = vec![
|
||||
"notify_config_change",
|
||||
"notify_global_setting_change",
|
||||
"notify_webhook_change",
|
||||
"notify_workspace_envs_change",
|
||||
"notify_workspace_key_change",
|
||||
"notify_runnable_version_change",
|
||||
"notify_token_invalidation",
|
||||
];
|
||||
|
||||
#[cfg(feature = "http_trigger")]
|
||||
channels.push("notify_http_trigger_change");
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
channels.push("notify_workspace_premium_change");
|
||||
|
||||
if let Err(e) = listener.listen_all(channels).await {
|
||||
tracing::error!(error = %e, "Could not listen to database");
|
||||
return None;
|
||||
}
|
||||
|
||||
return Some(listener);
|
||||
}
|
||||
|
||||
async fn retry_listen_pg(db: &Pool<Postgres>) -> PgListener {
|
||||
let mut listener = listen_pg(db).await;
|
||||
loop {
|
||||
if listener.is_none() {
|
||||
tracing::info!("Retrying listening to pg listen in 5 seconds");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
listener = listen_pg(db).await;
|
||||
} else {
|
||||
tracing::info!("Successfully connected to pg listen");
|
||||
return listener.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1921,24 +1921,6 @@ pub async fn monitor_db(
|
||||
}
|
||||
};
|
||||
|
||||
// Run every 5 minutes (10 iterations * 30s = 5 minutes)
|
||||
// Cleanup old notify events (older than 10 minutes)
|
||||
let cleanup_notify_events_f = async {
|
||||
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
match windmill_common::notify_events::cleanup_old_events(db, 10).await {
|
||||
Ok(count) if count > 0 => {
|
||||
tracing::debug!("Cleaned up {} old notify events", count);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error cleaning up notify events: {:?}", e);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
join!(
|
||||
expired_items_f,
|
||||
zombie_jobs_f,
|
||||
@@ -1958,7 +1940,6 @@ pub async fn monitor_db(
|
||||
cleanup_flow_iterator_data_f,
|
||||
cleanup_worker_group_stats_f,
|
||||
native_triggers_sync_f,
|
||||
cleanup_notify_events_f,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -803,47 +803,3 @@ pub async fn rebuild_dmap(client: &windmill_api_client::Client) -> bool {
|
||||
.status()
|
||||
.is_success()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dedicated Worker Protocol Helpers
|
||||
// ============================================================================
|
||||
|
||||
/// Result from parsing a dedicated worker stdout line
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DedicatedWorkerResult {
|
||||
/// Worker printed "start" indicating it's ready
|
||||
Start,
|
||||
/// Worker returned a successful result
|
||||
Success(serde_json::Value),
|
||||
/// Worker returned an error result
|
||||
Error(serde_json::Value),
|
||||
/// Line is not a protocol message (e.g., logs)
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Parse a line from dedicated worker stdout according to the protocol:
|
||||
/// - "start" -> Ready signal
|
||||
/// - "wm_res[success]:JSON" -> Success with result
|
||||
/// - "wm_res[error]:JSON" -> Error with details
|
||||
/// - anything else -> Other (logs)
|
||||
pub fn parse_dedicated_worker_line(line: &str) -> DedicatedWorkerResult {
|
||||
if line == "start" {
|
||||
return DedicatedWorkerResult::Start;
|
||||
}
|
||||
|
||||
if let Some(json_str) = line.strip_prefix("wm_res[success]:") {
|
||||
match serde_json::from_str(json_str) {
|
||||
Ok(value) => return DedicatedWorkerResult::Success(value),
|
||||
Err(_) => return DedicatedWorkerResult::Other(line.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(json_str) = line.strip_prefix("wm_res[error]:") {
|
||||
match serde_json::from_str(json_str) {
|
||||
Ok(value) => return DedicatedWorkerResult::Error(value),
|
||||
Err(_) => return DedicatedWorkerResult::Other(line.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
DedicatedWorkerResult::Other(line.to_string())
|
||||
}
|
||||
|
||||
152
backend/tests/fixtures/bun_edge_cases.sql
vendored
152
backend/tests/fixtures/bun_edge_cases.sql
vendored
@@ -1,152 +0,0 @@
|
||||
-- Fixture for Bun edge case tests
|
||||
-- Tests deeply nested imports (level1 -> level2 -> level3)
|
||||
|
||||
-- Level 3: Base script (deepest level)
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
export function main() {
|
||||
return "level3";
|
||||
}
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/nested/level3', 20001, 'bun', '');
|
||||
|
||||
-- Level 2: Imports level3 using RELATIVE path (./level3.ts)
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
import { main as level3 } from "./level3.ts";
|
||||
|
||||
export function main() {
|
||||
return "level2 -> " + level3();
|
||||
}
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/nested/level2', 20002, 'bun', '');
|
||||
|
||||
-- Level 1: Imports level2 using RELATIVE path (./level2.ts)
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
import { main as level2 } from "./level2.ts";
|
||||
|
||||
export function main() {
|
||||
return "level1 -> " + level2();
|
||||
}
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/nested/level1', 20003, 'bun', '');
|
||||
|
||||
-- Script with preprocessor function
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, has_preprocessor) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
export function preprocessor(value: number) {
|
||||
return { value: value * 2 };
|
||||
}
|
||||
|
||||
export function main(value: number) {
|
||||
return value + 100;
|
||||
}
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"value":{"type":"number"}},"required":["value"],"type":"object"}',
|
||||
'Script with preprocessor',
|
||||
'',
|
||||
'f/edge_cases/with_preprocessor', 20004, 'bun', '', true);
|
||||
|
||||
-- Script with nodejs annotation
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'//nodejs
|
||||
|
||||
export function main() {
|
||||
return process.version;
|
||||
}
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'NodeJS mode script',
|
||||
'',
|
||||
'f/edge_cases/nodejs_mode', 20005, 'bun', '');
|
||||
|
||||
-- Script with nobundling annotation
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'//nobundling
|
||||
|
||||
export function main() {
|
||||
return "no bundle";
|
||||
}
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'No bundling mode script',
|
||||
'',
|
||||
'f/edge_cases/nobundling_mode', 20006, 'bun', '');
|
||||
|
||||
-- Script that uses circular-ish import pattern (A imports B, B imports C, test imports A and C)
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
export const SHARED_VALUE = "shared";
|
||||
|
||||
export function main() {
|
||||
return SHARED_VALUE;
|
||||
}
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/circular/shared', 20007, 'bun', '');
|
||||
|
||||
-- module_a uses RELATIVE path import (./shared.ts)
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
import { SHARED_VALUE } from "./shared.ts";
|
||||
|
||||
export function getValue() {
|
||||
return "from_a_" + SHARED_VALUE;
|
||||
}
|
||||
|
||||
export function main() {
|
||||
return getValue();
|
||||
}
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/circular/module_a', 20008, 'bun', '');
|
||||
|
||||
-- module_b uses ABSOLUTE path import (/f/circular/shared.ts)
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
import { SHARED_VALUE } from "/f/circular/shared.ts";
|
||||
|
||||
export function getValue() {
|
||||
return "from_b_" + SHARED_VALUE;
|
||||
}
|
||||
|
||||
export function main() {
|
||||
return getValue();
|
||||
}
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/circular/module_b', 20009, 'bun', '');
|
||||
84
backend/tests/fixtures/schedule_push.sql
vendored
84
backend/tests/fixtures/schedule_push.sql
vendored
@@ -1,84 +0,0 @@
|
||||
-- Fixture for schedule push tests
|
||||
-- Sets up scripts, flows, users, and schedules needed to test push_scheduled_job
|
||||
|
||||
-- Password entries for auth resolution
|
||||
INSERT INTO password (email, password_hash, login_type, super_admin, verified, name)
|
||||
VALUES
|
||||
('test@windmill.dev', 'dummy_hash', 'password', false, true, 'Test User'),
|
||||
('obo@windmill.dev', 'dummy_hash', 'password', false, true, 'OBO User')
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- OBO user in workspace
|
||||
INSERT INTO usr (workspace_id, email, username, is_admin, role)
|
||||
VALUES ('test-workspace', 'obo@windmill.dev', 'obo-user', false, 'Developer')
|
||||
ON CONFLICT (workspace_id, username) DO NOTHING;
|
||||
|
||||
-- A simple script
|
||||
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind)
|
||||
VALUES (
|
||||
'test-workspace', 'test-user',
|
||||
'export async function main() { return "ok"; }',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'Test script', '', 'f/system/test_script', 100001, 'deno', '', 'script'
|
||||
);
|
||||
|
||||
-- A script with on_behalf_of_email
|
||||
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, on_behalf_of_email)
|
||||
VALUES (
|
||||
'test-workspace', 'test-user',
|
||||
'export async function main() { return "obo"; }',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'OBO script', '', 'f/system/obo_script', 100002, 'deno', '', 'script', 'obo@windmill.dev'
|
||||
);
|
||||
|
||||
-- A script with a tag
|
||||
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, tag)
|
||||
VALUES (
|
||||
'test-workspace', 'test-user',
|
||||
'export async function main() { return "tagged"; }',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'Tagged script', '', 'f/system/tagged_script', 100003, 'deno', '', 'script', 'custom-tag'
|
||||
);
|
||||
|
||||
-- A script with timeout
|
||||
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, timeout)
|
||||
VALUES (
|
||||
'test-workspace', 'test-user',
|
||||
'export async function main() { return "timeout"; }',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'Timeout script', '', 'f/system/timeout_script', 100004, 'deno', '', 'script', 300
|
||||
);
|
||||
|
||||
-- A flow
|
||||
INSERT INTO flow (workspace_id, summary, description, path, versions, schema, value, edited_by)
|
||||
VALUES (
|
||||
'test-workspace', 'Test flow', '', 'f/system/test_flow', '{200001}',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'{"modules": [{"id": "a", "value": {"path": "f/system/test_script", "type": "script", "input_transforms": {}}}]}',
|
||||
'test-user'
|
||||
);
|
||||
|
||||
INSERT INTO flow_version (id, workspace_id, path, schema, value, created_by)
|
||||
VALUES (
|
||||
200001, 'test-workspace', 'f/system/test_flow',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'{"modules": [{"id": "a", "value": {"path": "f/system/test_script", "type": "script", "input_transforms": {}}}]}',
|
||||
'test-user'
|
||||
);
|
||||
|
||||
-- A flow with on_behalf_of_email
|
||||
INSERT INTO flow (workspace_id, summary, description, path, versions, schema, value, edited_by, on_behalf_of_email)
|
||||
VALUES (
|
||||
'test-workspace', 'OBO flow', '', 'f/system/obo_flow', '{200002}',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'{"modules": [{"id": "a", "value": {"path": "f/system/test_script", "type": "script", "input_transforms": {}}}]}',
|
||||
'test-user', 'obo@windmill.dev'
|
||||
);
|
||||
|
||||
INSERT INTO flow_version (id, workspace_id, path, schema, value, created_by)
|
||||
VALUES (
|
||||
200002, 'test-workspace', 'f/system/obo_flow',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'{"modules": [{"id": "a", "value": {"path": "f/system/test_script", "type": "script", "input_transforms": {}}}]}',
|
||||
'test-user'
|
||||
);
|
||||
@@ -1,835 +0,0 @@
|
||||
/*!
|
||||
* Tests for the polling-based notify_event system that replaces PostgreSQL LISTEN/NOTIFY.
|
||||
*
|
||||
* These tests verify:
|
||||
* 1. Database triggers correctly insert events into notify_event table
|
||||
* 2. Polling functions retrieve events correctly
|
||||
* 3. Cleanup functions delete old events
|
||||
* 4. All notification channels work as expected
|
||||
*/
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::notify_events::{cleanup_old_events, get_latest_event_id, poll_notify_events};
|
||||
|
||||
mod common;
|
||||
|
||||
/// Helper to insert a test event directly
|
||||
async fn insert_test_event(db: &Pool<Postgres>, channel: &str, payload: &str) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
||||
)
|
||||
.bind(channel)
|
||||
.bind(payload)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.expect("Failed to insert test event")
|
||||
}
|
||||
|
||||
/// Helper to count events for a channel
|
||||
async fn count_events_for_channel(db: &Pool<Postgres>, channel: &str) -> i64 {
|
||||
let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_event WHERE channel = $1")
|
||||
.bind(channel)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.expect("Failed to count events");
|
||||
result.0
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Basic Functionality Tests
|
||||
// ============================================================================
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_get_latest_event_id_returns_valid_id(db: Pool<Postgres>) {
|
||||
// Get current latest id
|
||||
let latest_id = get_latest_event_id(&db).await.expect("Should get latest event id");
|
||||
assert!(latest_id >= 0, "Latest id should be non-negative");
|
||||
|
||||
// Insert a new event and verify latest_id increases
|
||||
let new_id = insert_test_event(&db, "test_latest_id", "payload").await;
|
||||
let new_latest_id = get_latest_event_id(&db).await.expect("Should get latest event id");
|
||||
assert!(new_latest_id >= new_id, "Latest id should be >= new event id");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_get_latest_event_id_with_events(db: Pool<Postgres>) {
|
||||
let _id1 = insert_test_event(&db, "test_channel_1", "payload1").await;
|
||||
let _id2 = insert_test_event(&db, "test_channel_2", "payload2").await;
|
||||
let id3 = insert_test_event(&db, "test_channel_3", "payload3").await;
|
||||
|
||||
let latest_id = get_latest_event_id(&db).await.expect("Should get latest event id");
|
||||
assert!(latest_id >= id3, "Latest id should be >= last inserted id");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_poll_notify_events_no_new_events(db: Pool<Postgres>) {
|
||||
// Get latest id first
|
||||
let latest_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Poll from the latest id - should return empty since no new events
|
||||
let events = poll_notify_events(&db, latest_id).await.expect("Should poll events");
|
||||
assert!(events.is_empty(), "Should return empty vec when polling from latest id");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_poll_notify_events_returns_new_events(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
let _id1 = insert_test_event(&db, "test_poll_channel", "payload1").await;
|
||||
let _id2 = insert_test_event(&db, "test_poll_channel", "payload2").await;
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
assert!(events.len() >= 2, "Should return at least 2 new events");
|
||||
|
||||
// Verify the events we inserted are present
|
||||
let our_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "test_poll_channel")
|
||||
.collect();
|
||||
assert_eq!(our_events.len(), 2, "Should have exactly our 2 test events");
|
||||
|
||||
// Verify ordering (ascending by id)
|
||||
assert!(our_events[0].id < our_events[1].id, "Events should be ordered by id ascending");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_poll_notify_events_respects_last_event_id(db: Pool<Postgres>) {
|
||||
let id1 = insert_test_event(&db, "test_respect_id", "payload1").await;
|
||||
let _id2 = insert_test_event(&db, "test_respect_id", "payload2").await;
|
||||
let _id3 = insert_test_event(&db, "test_respect_id", "payload3").await;
|
||||
|
||||
// Poll from id1 should only return id2 and id3
|
||||
let events = poll_notify_events(&db, id1).await.expect("Should poll events");
|
||||
let our_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "test_respect_id")
|
||||
.collect();
|
||||
|
||||
assert_eq!(our_events.len(), 2, "Should only return events after id1");
|
||||
assert!(our_events.iter().all(|e| e.id > id1), "All events should have id > id1");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_cleanup_old_events(db: Pool<Postgres>) {
|
||||
// Use unique channel names to avoid interference from other tests
|
||||
let old_channel = format!("test_cleanup_old_{}", uuid::Uuid::new_v4());
|
||||
let recent_channel = format!("test_cleanup_recent_{}", uuid::Uuid::new_v4());
|
||||
|
||||
// Insert an event with old timestamp
|
||||
sqlx::query(
|
||||
"INSERT INTO notify_event (channel, payload, created_at) VALUES ($1, $2, now() - interval '15 minutes')",
|
||||
)
|
||||
.bind(&old_channel)
|
||||
.bind("old_payload")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert old event");
|
||||
|
||||
// Insert a recent event
|
||||
sqlx::query(
|
||||
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2)",
|
||||
)
|
||||
.bind(&recent_channel)
|
||||
.bind("recent_payload")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert recent event");
|
||||
|
||||
// Count before cleanup
|
||||
let old_count_before = count_events_for_channel(&db, &old_channel).await;
|
||||
assert_eq!(old_count_before, 1, "Should have 1 old event before cleanup");
|
||||
|
||||
// Cleanup events older than 10 minutes
|
||||
let deleted = cleanup_old_events(&db, 10).await.expect("Should cleanup events");
|
||||
assert!(deleted >= 1, "Should delete at least 1 old event");
|
||||
|
||||
// Verify old event is gone
|
||||
let old_count = count_events_for_channel(&db, &old_channel).await;
|
||||
assert_eq!(old_count, 0, "Old event should be deleted");
|
||||
|
||||
// Verify recent event is still there
|
||||
let recent_count = count_events_for_channel(&db, &recent_channel).await;
|
||||
assert_eq!(recent_count, 1, "Recent event should still exist");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Database Trigger Tests - Verify triggers insert events correctly
|
||||
// ============================================================================
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_config_change(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Insert or update a config entry
|
||||
sqlx::query(
|
||||
"INSERT INTO config (name, config) VALUES ('test_config_trigger', '{}'::jsonb)
|
||||
ON CONFLICT (name) DO UPDATE SET config = '{}'::jsonb",
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert config");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let config_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_config_change" && e.payload == "test_config_trigger")
|
||||
.collect();
|
||||
|
||||
assert!(!config_events.is_empty(), "Should have notify_config_change event");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_global_setting_change_insert(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Use a unique setting name for testing
|
||||
let setting_name = format!("test_setting_{}", uuid::Uuid::new_v4());
|
||||
|
||||
// Insert a global setting
|
||||
sqlx::query("INSERT INTO global_settings (name, value) VALUES ($1, '{}'::jsonb)")
|
||||
.bind(&setting_name)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert global setting");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let setting_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name)
|
||||
.collect();
|
||||
|
||||
assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on insert");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_global_setting_change_update(db: Pool<Postgres>) {
|
||||
// Use a unique setting name for testing
|
||||
let setting_name = format!("test_setting_update_{}", uuid::Uuid::new_v4());
|
||||
|
||||
// First insert
|
||||
sqlx::query("INSERT INTO global_settings (name, value) VALUES ($1, '{}'::jsonb)")
|
||||
.bind(&setting_name)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert global setting");
|
||||
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Update the setting
|
||||
sqlx::query("UPDATE global_settings SET value = '{\"updated\": true}'::jsonb WHERE name = $1")
|
||||
.bind(&setting_name)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to update global setting");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let setting_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name)
|
||||
.collect();
|
||||
|
||||
assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on update");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_global_setting_change_delete(db: Pool<Postgres>) {
|
||||
// Use a unique setting name for testing
|
||||
let setting_name = format!("test_setting_delete_{}", uuid::Uuid::new_v4());
|
||||
|
||||
// First insert
|
||||
sqlx::query("INSERT INTO global_settings (name, value) VALUES ($1, '{}'::jsonb)")
|
||||
.bind(&setting_name)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert global setting");
|
||||
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Delete the setting
|
||||
sqlx::query("DELETE FROM global_settings WHERE name = $1")
|
||||
.bind(&setting_name)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to delete global setting");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let setting_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name)
|
||||
.collect();
|
||||
|
||||
assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on delete");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_workspace_envs_change(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Insert a workspace env (test-workspace exists from fixture)
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_env (workspace_id, name, value) VALUES ('test-workspace', 'TEST_ENV_VAR', 'test_value')
|
||||
ON CONFLICT (workspace_id, name) DO UPDATE SET value = 'test_value_updated'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert workspace env");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let env_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_workspace_envs_change" && e.payload == "test-workspace")
|
||||
.collect();
|
||||
|
||||
assert!(!env_events.is_empty(), "Should have notify_workspace_envs_change event");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_workspace_key_change(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Insert a workspace key (base fixture already has one, so this will conflict and update)
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace_key (workspace_id, kind, key) VALUES ('test-workspace', 'cloud', 'test_key_value')
|
||||
ON CONFLICT (workspace_id, kind) DO UPDATE SET key = 'test_key_value_updated'",
|
||||
)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert workspace key");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let key_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_workspace_key_change" && e.payload == "test-workspace")
|
||||
.collect();
|
||||
|
||||
assert!(!key_events.is_empty(), "Should have notify_workspace_key_change event");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_token_invalidation(db: Pool<Postgres>) {
|
||||
// First insert a session token
|
||||
let token = format!("test_token_{}", uuid::Uuid::new_v4());
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token, label, email, workspace_id, owner, expiration)
|
||||
VALUES ($1, 'session', 'test@test.com', 'test-workspace', 'test-user', now() + interval '1 hour')",
|
||||
)
|
||||
.bind(&token)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert token");
|
||||
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Delete the token (should trigger notification)
|
||||
sqlx::query("DELETE FROM token WHERE token = $1")
|
||||
.bind(&token)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to delete token");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let token_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_token_invalidation" && e.payload == token)
|
||||
.collect();
|
||||
|
||||
assert!(!token_events.is_empty(), "Should have notify_token_invalidation event");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_webhook_change(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Update webhook setting (workspace_settings exists from base fixture)
|
||||
sqlx::query("UPDATE workspace_settings SET webhook = 'https://test.webhook.com' WHERE workspace_id = 'test-workspace'")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to update webhook");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let webhook_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_webhook_change" && e.payload == "test-workspace")
|
||||
.collect();
|
||||
|
||||
assert!(!webhook_events.is_empty(), "Should have notify_webhook_change event");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_workspace_premium_change(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Toggle premium status
|
||||
sqlx::query("UPDATE workspace SET premium = NOT premium WHERE id = 'test-workspace'")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to update workspace premium");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let premium_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_workspace_premium_change" && e.payload == "test-workspace")
|
||||
.collect();
|
||||
|
||||
assert!(!premium_events.is_empty(), "Should have notify_workspace_premium_change event");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HTTP Trigger Tests
|
||||
// ============================================================================
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_http_trigger_change(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
let trigger_path = format!("test_http_trigger_{}", uuid::Uuid::new_v4());
|
||||
|
||||
// Insert an HTTP trigger
|
||||
sqlx::query(
|
||||
"INSERT INTO http_trigger (path, route_path, route_path_key, script_path, is_flow, workspace_id, edited_by, email, http_method, authentication_method)
|
||||
VALUES ($1, '/test/route', '/test/route', 'test/script', false, 'test-workspace', 'test-user', 'test@test.com', 'get', 'none')",
|
||||
)
|
||||
.bind(&trigger_path)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert HTTP trigger");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let http_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_http_trigger_change")
|
||||
.filter(|e| e.payload.contains("test-workspace") && e.payload.contains(&trigger_path))
|
||||
.collect();
|
||||
|
||||
assert!(!http_events.is_empty(), "Should have notify_http_trigger_change event");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Script/Flow Version Change Tests
|
||||
// ============================================================================
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_runnable_version_change_script(db: Pool<Postgres>) {
|
||||
// First create a script without lock
|
||||
let script_path = format!("f/test/script_{}", uuid::Uuid::new_v4());
|
||||
let script_hash: i64 = rand::random::<i64>().abs();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, kind)
|
||||
VALUES ('test-workspace', $1, $2, 'test', 'test', 'def main(): pass', 'test-user', 'python3', 'script')",
|
||||
)
|
||||
.bind(script_hash)
|
||||
.bind(&script_path)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert script");
|
||||
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Update the lock field (this should trigger the notification)
|
||||
sqlx::query("UPDATE script SET lock = 'test_lock_content' WHERE hash = $1")
|
||||
.bind(script_hash)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to update script lock");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let script_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_runnable_version_change")
|
||||
.filter(|e| e.payload.contains("test-workspace") && e.payload.contains("script"))
|
||||
.collect();
|
||||
|
||||
assert!(!script_events.is_empty(), "Should have notify_runnable_version_change event for script");
|
||||
|
||||
// Verify payload format: workspace_id:source_type:path:kind
|
||||
let parts: Vec<&str> = script_events[0].payload.split(':').collect();
|
||||
assert!(parts.len() >= 4, "Payload should have at least 4 parts");
|
||||
assert_eq!(parts[0], "test-workspace", "First part should be workspace_id");
|
||||
assert_eq!(parts[1], "script", "Second part should be 'script'");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_trigger_notify_runnable_version_change_flow(db: Pool<Postgres>) {
|
||||
// First create a flow with empty versions array
|
||||
let flow_path = format!("f/test/flow_{}", uuid::Uuid::new_v4());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, versions)
|
||||
VALUES ('test-workspace', $1, 'test', 'test', '{}'::jsonb, 'test-user', '{}'::json, ARRAY[]::bigint[])",
|
||||
)
|
||||
.bind(&flow_path)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert flow");
|
||||
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Update the flow's versions array (this triggers flow_versions_append_trigger)
|
||||
sqlx::query(
|
||||
"UPDATE flow SET versions = array_append(versions, 1::bigint) WHERE workspace_id = 'test-workspace' AND path = $1",
|
||||
)
|
||||
.bind(&flow_path)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to update flow versions");
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let flow_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "notify_runnable_version_change")
|
||||
.filter(|e| e.payload.contains("test-workspace") && e.payload.contains("flow"))
|
||||
.collect();
|
||||
|
||||
assert!(!flow_events.is_empty(), "Should have notify_runnable_version_change event for flow");
|
||||
|
||||
// Verify payload format
|
||||
let parts: Vec<&str> = flow_events[0].payload.split(':').collect();
|
||||
assert!(parts.len() >= 4, "Payload should have at least 4 parts");
|
||||
assert_eq!(parts[0], "test-workspace", "First part should be workspace_id");
|
||||
assert_eq!(parts[1], "flow", "Second part should be 'flow'");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Concurrent Access Tests
|
||||
// ============================================================================
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_concurrent_event_insertion(db: Pool<Postgres>) {
|
||||
// Use a unique channel name for this test run
|
||||
let channel = format!("test_concurrent_{}", uuid::Uuid::new_v4());
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Insert multiple events concurrently
|
||||
let handles: Vec<_> = (0..10)
|
||||
.map(|i| {
|
||||
let db = db.clone();
|
||||
let ch = channel.clone();
|
||||
tokio::spawn(async move {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
||||
)
|
||||
.bind(&ch)
|
||||
.bind(format!("payload_{}", i))
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("Failed to insert event")
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Wait for all insertions
|
||||
for handle in handles {
|
||||
handle.await.expect("Task should complete");
|
||||
}
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let concurrent_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == channel)
|
||||
.collect();
|
||||
|
||||
assert_eq!(concurrent_events.len(), 10, "Should have all 10 concurrent events");
|
||||
|
||||
// Verify all events have unique IDs
|
||||
let ids: std::collections::HashSet<i64> = concurrent_events.iter().map(|e| e.id).collect();
|
||||
assert_eq!(ids.len(), 10, "All events should have unique IDs");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_polling_isolation(db: Pool<Postgres>) {
|
||||
// Use a unique channel name for this test
|
||||
let channel = format!("test_isolation_{}", uuid::Uuid::new_v4());
|
||||
|
||||
// Get baseline before inserting
|
||||
let baseline_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Insert some events
|
||||
let id1 = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
||||
)
|
||||
.bind(&channel)
|
||||
.bind("payload1")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("Failed to insert event");
|
||||
|
||||
let id2 = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
||||
)
|
||||
.bind(&channel)
|
||||
.bind("payload2")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("Failed to insert event");
|
||||
|
||||
let _id3 = sqlx::query_scalar::<_, i64>(
|
||||
"INSERT INTO notify_event (channel, payload) VALUES ($1, $2) RETURNING id",
|
||||
)
|
||||
.bind(&channel)
|
||||
.bind("payload3")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("Failed to insert event");
|
||||
|
||||
// Two different "consumers" polling from different points
|
||||
let events_from_baseline = poll_notify_events(&db, baseline_id).await.expect("Should poll events");
|
||||
let events_from_id1 = poll_notify_events(&db, id1).await.expect("Should poll events");
|
||||
let events_from_id2 = poll_notify_events(&db, id2).await.expect("Should poll events");
|
||||
|
||||
// Filter to our test events
|
||||
let from_baseline: Vec<_> = events_from_baseline.iter().filter(|e| e.channel == channel).collect();
|
||||
let from_id1: Vec<_> = events_from_id1.iter().filter(|e| e.channel == channel).collect();
|
||||
let from_id2: Vec<_> = events_from_id2.iter().filter(|e| e.channel == channel).collect();
|
||||
|
||||
assert_eq!(from_baseline.len(), 3, "Polling from baseline should include all 3 events");
|
||||
assert_eq!(from_id1.len(), 2, "Polling from id1 should include id2 and id3");
|
||||
assert_eq!(from_id2.len(), 1, "Polling from id2 should include only id3");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Edge Case Tests
|
||||
// ============================================================================
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_empty_payload(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
insert_test_event(&db, "test_empty_payload", "").await;
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let empty_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "test_empty_payload")
|
||||
.collect();
|
||||
|
||||
assert_eq!(empty_events.len(), 1, "Should have event with empty payload");
|
||||
assert_eq!(empty_events[0].payload, "", "Payload should be empty string");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_large_payload(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
// Create a large payload (1KB)
|
||||
let large_payload = "x".repeat(1024);
|
||||
insert_test_event(&db, "test_large_payload", &large_payload).await;
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let large_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "test_large_payload")
|
||||
.collect();
|
||||
|
||||
assert_eq!(large_events.len(), 1, "Should have event with large payload");
|
||||
assert_eq!(large_events[0].payload.len(), 1024, "Payload should be preserved");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_special_characters_in_payload(db: Pool<Postgres>) {
|
||||
let before_id = get_latest_event_id(&db).await.unwrap();
|
||||
|
||||
let special_payload = r#"{"key": "value with \"quotes\" and 'apostrophes'", "unicode": "日本語", "newline": "line1\nline2"}"#;
|
||||
insert_test_event(&db, "test_special_chars", special_payload).await;
|
||||
|
||||
let events = poll_notify_events(&db, before_id).await.expect("Should poll events");
|
||||
let special_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.channel == "test_special_chars")
|
||||
.collect();
|
||||
|
||||
assert_eq!(special_events.len(), 1, "Should have event with special characters");
|
||||
assert_eq!(special_events[0].payload, special_payload, "Special characters should be preserved");
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_cleanup_with_no_old_events(db: Pool<Postgres>) {
|
||||
// Use a unique channel name for this test
|
||||
let channel = format!("test_no_old_{}", uuid::Uuid::new_v4());
|
||||
|
||||
// Insert only recent events
|
||||
sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)")
|
||||
.bind(&channel)
|
||||
.bind("recent1")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert event");
|
||||
|
||||
sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)")
|
||||
.bind(&channel)
|
||||
.bind("recent2")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert event");
|
||||
|
||||
let before_count = count_events_for_channel(&db, &channel).await;
|
||||
assert_eq!(before_count, 2, "Should have 2 recent events");
|
||||
|
||||
// Cleanup old events (none of our events should be deleted since they're recent)
|
||||
let _deleted = cleanup_old_events(&db, 10).await.expect("Should cleanup events");
|
||||
|
||||
let after_count = count_events_for_channel(&db, &channel).await;
|
||||
assert_eq!(after_count, 2, "Recent events should not be deleted");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-Server Integration Tests
|
||||
// ============================================================================
|
||||
// These tests start two actual windmill server processes on different ports
|
||||
// with LISTEN_NEW_EVENTS_INTERVAL_SEC=1, trigger DB changes, and verify
|
||||
// both servers process the events via their log output.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct ServerProcess {
|
||||
child: Child,
|
||||
log_lines: Arc<Mutex<Vec<String>>>,
|
||||
_stdout_handle: std::thread::JoinHandle<()>,
|
||||
_stderr_handle: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ServerProcess {
|
||||
fn start(port: u16, db_url: &str) -> Self {
|
||||
let binary = std::env::var("WINDMILL_BINARY")
|
||||
.unwrap_or_else(|_| format!("{}/target/debug/windmill", env!("CARGO_MANIFEST_DIR")));
|
||||
|
||||
let mut child = Command::new(&binary)
|
||||
.env("DATABASE_URL", db_url)
|
||||
.env("MODE", "server")
|
||||
.env("PORT", port.to_string())
|
||||
.env("LISTEN_NEW_EVENTS_INTERVAL_SEC", "1")
|
||||
.env("RUST_LOG", "info")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap_or_else(|e| panic!("Failed to start windmill on port {port}: {e}"));
|
||||
|
||||
let stdout = child.stdout.take().expect("Failed to capture stdout");
|
||||
let stderr = child.stderr.take().expect("Failed to capture stderr");
|
||||
let log_lines = Arc::new(Mutex::new(Vec::new()));
|
||||
let log_lines_stdout = log_lines.clone();
|
||||
let log_lines_stderr = log_lines.clone();
|
||||
|
||||
// Read both stdout and stderr into the same log buffer
|
||||
let _reader_handle = std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
log_lines_stdout.lock().unwrap().push(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
let _stderr_handle = std::thread::spawn(move || {
|
||||
let reader = BufReader::new(stderr);
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
log_lines_stderr.lock().unwrap().push(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ServerProcess { child, log_lines, _stdout_handle: _reader_handle, _stderr_handle }
|
||||
}
|
||||
|
||||
fn logs_contain(&self, needle: &str) -> bool {
|
||||
self.log_lines.lock().unwrap().iter().any(|l| l.contains(needle))
|
||||
}
|
||||
|
||||
fn dump_logs(&self) -> String {
|
||||
self.log_lines.lock().unwrap().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ServerProcess {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for server to be ready by polling its HTTP endpoint.
|
||||
async fn wait_for_server(port: u16, timeout_secs: u64) -> bool {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("http://127.0.0.1:{}/api/version", port);
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if client.get(&url).send().await.is_ok() {
|
||||
return true;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Helper to get a database connection (only used by the e2e multi-server test)
|
||||
async fn get_db() -> Pool<Postgres> {
|
||||
let database_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://postgres:changeme@localhost:5432/windmill".to_string());
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.expect("Failed to connect to database")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "slow - starts two server processes with 1s poll interval"]
|
||||
async fn test_two_server_processes_both_receive_event() {
|
||||
let db_url = std::env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://postgres:changeme@localhost:5432/windmill".to_string());
|
||||
|
||||
// Start two server processes on different ports with 1s poll interval
|
||||
let mut server_a = ServerProcess::start(19100, &db_url);
|
||||
let mut server_b = ServerProcess::start(19200, &db_url);
|
||||
|
||||
// Wait for both servers to be ready
|
||||
let (ready_a, ready_b) = tokio::join!(
|
||||
wait_for_server(19100, 30),
|
||||
wait_for_server(19200, 30),
|
||||
);
|
||||
assert!(ready_a, "Server A (port 19100) failed to start. Logs:\n{}", server_a.dump_logs());
|
||||
assert!(ready_b, "Server B (port 19200) failed to start. Logs:\n{}", server_b.dump_logs());
|
||||
|
||||
// Give servers a moment to complete their first poll cycle
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
// Trigger a global setting change via direct DB insert
|
||||
let db = get_db().await;
|
||||
let setting_name = format!("test_e2e_{}", uuid::Uuid::new_v4());
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ($1, '\"e2e_test\"'::jsonb)
|
||||
ON CONFLICT (name) DO UPDATE SET value = '\"e2e_test\"'::jsonb",
|
||||
)
|
||||
.bind(&setting_name)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to insert global setting");
|
||||
|
||||
// Wait for at least 2 poll cycles (interval is 1s)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let needle = format!("Global setting change detected: {}", setting_name);
|
||||
assert!(
|
||||
server_a.logs_contain(&needle),
|
||||
"Server A should have processed the global setting event.\nSearching for: {}\nServer A logs:\n{}",
|
||||
needle, server_a.dump_logs()
|
||||
);
|
||||
assert!(
|
||||
server_b.logs_contain(&needle),
|
||||
"Server B should have processed the global setting event.\nSearching for: {}\nServer B logs:\n{}",
|
||||
needle, server_b.dump_logs()
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
sqlx::query("DELETE FROM global_settings WHERE name = $1")
|
||||
.bind(&setting_name)
|
||||
.execute(&db)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Explicitly kill before drop to avoid port conflicts with other tests
|
||||
let _ = server_a.child.kill();
|
||||
let _ = server_b.child.kill();
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
mod common;
|
||||
|
||||
mod schedule_push {
|
||||
use chrono::Utc;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::db::Authed;
|
||||
use windmill_common::jobs::{JobKind, JobTriggerKind};
|
||||
use windmill_common::schedule::Schedule;
|
||||
use windmill_common::scripts::ScriptHash;
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_queue::jobs::{handle_maybe_scheduled_job, MiniCompletedJob};
|
||||
use windmill_queue::schedule::push_scheduled_job;
|
||||
|
||||
fn make_schedule(overrides: impl FnOnce(&mut Schedule)) -> Schedule {
|
||||
let mut s = Schedule {
|
||||
workspace_id: "test-workspace".to_string(),
|
||||
path: "f/system/test_schedule".to_string(),
|
||||
edited_by: "test-user".to_string(),
|
||||
edited_at: Utc::now(),
|
||||
schedule: "0 0 */5 * * *".to_string(),
|
||||
timezone: "UTC".to_string(),
|
||||
enabled: true,
|
||||
script_path: "f/system/test_script".to_string(),
|
||||
is_flow: false,
|
||||
args: None,
|
||||
extra_perms: serde_json::json!({}),
|
||||
email: "test@windmill.dev".to_string(),
|
||||
error: None,
|
||||
on_failure: None,
|
||||
on_failure_times: None,
|
||||
on_failure_exact: None,
|
||||
on_failure_extra_args: None,
|
||||
on_recovery: None,
|
||||
on_recovery_times: None,
|
||||
on_recovery_extra_args: None,
|
||||
on_success: None,
|
||||
on_success_extra_args: None,
|
||||
ws_error_handler_muted: false,
|
||||
retry: None,
|
||||
no_flow_overlap: false,
|
||||
summary: None,
|
||||
description: None,
|
||||
tag: None,
|
||||
paused_until: None,
|
||||
cron_version: None,
|
||||
dynamic_skip: None,
|
||||
};
|
||||
overrides(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
fn make_authed() -> Authed {
|
||||
Authed {
|
||||
email: "test@windmill.dev".to_string(),
|
||||
username: "test-user".to_string(),
|
||||
is_admin: true,
|
||||
is_operator: false,
|
||||
groups: vec![],
|
||||
folders: vec![],
|
||||
scopes: None,
|
||||
token_prefix: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_completed_job(schedule: &Schedule) -> MiniCompletedJob {
|
||||
MiniCompletedJob {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
workspace_id: schedule.workspace_id.clone(),
|
||||
runnable_id: Some(ScriptHash(100001)),
|
||||
scheduled_for: Utc::now() - chrono::Duration::minutes(5),
|
||||
parent_job: None,
|
||||
flow_innermost_root_job: None,
|
||||
runnable_path: Some(schedule.script_path.clone()),
|
||||
kind: JobKind::Script,
|
||||
started_at: Some(Utc::now() - chrono::Duration::minutes(4)),
|
||||
permissioned_as: username_to_permissioned_as(&schedule.edited_by),
|
||||
created_by: schedule.edited_by.clone(),
|
||||
script_lang: None,
|
||||
permissioned_as_email: schedule.email.clone(),
|
||||
flow_step_id: None,
|
||||
trigger_kind: Some(JobTriggerKind::Schedule),
|
||||
trigger: Some(schedule.path.clone()),
|
||||
priority: None,
|
||||
concurrent_limit: None,
|
||||
tag: "deno".to_string(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
runnable_settings_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn count_queued_jobs(db: &Pool<Postgres>) -> i64 {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM v2_job_queue")
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn get_queued_job(
|
||||
db: &Pool<Postgres>,
|
||||
) -> Option<(
|
||||
String, // workspace_id
|
||||
Option<String>, // runnable_path
|
||||
Option<String>, // trigger
|
||||
Option<String>, // trigger_kind as text
|
||||
)> {
|
||||
sqlx::query_as::<_, (String, Option<String>, Option<String>, Option<String>)>(
|
||||
"SELECT j.workspace_id, j.runnable_path, j.trigger, j.trigger_kind::text
|
||||
FROM v2_job j JOIN v2_job_queue q ON j.id = q.id
|
||||
LIMIT 1",
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: basic script schedule
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_script_schedule(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|_| {});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let tx = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
let (ws, path, trigger, trigger_kind) = get_queued_job(&db).await.unwrap();
|
||||
assert_eq!(ws, "test-workspace");
|
||||
assert_eq!(path.as_deref(), Some("f/system/test_script"));
|
||||
assert_eq!(trigger.as_deref(), Some("f/system/test_schedule"));
|
||||
assert_eq!(trigger_kind.as_deref(), Some("schedule"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: flow schedule
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_flow_schedule(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.is_flow = true;
|
||||
s.script_path = "f/system/test_flow".to_string();
|
||||
s.path = "f/system/flow_schedule".to_string();
|
||||
});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let tx = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
let (_, path, trigger, _) = get_queued_job(&db).await.unwrap();
|
||||
assert_eq!(path.as_deref(), Some("f/system/test_flow"));
|
||||
assert_eq!(trigger.as_deref(), Some("f/system/flow_schedule"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: on_behalf_of_email (script)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_script_on_behalf_of_email(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.script_path = "f/system/obo_script".to_string();
|
||||
s.path = "f/system/obo_schedule".to_string();
|
||||
});
|
||||
|
||||
// No pre-computed authed: forces the obo path inside push_scheduled_job
|
||||
let tx = db.begin().await?;
|
||||
let tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
|
||||
let email = sqlx::query_scalar::<_, String>(
|
||||
"SELECT permissioned_as_email FROM v2_job j JOIN v2_job_queue q ON j.id = q.id LIMIT 1",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(email, "obo@windmill.dev");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: on_behalf_of_email (flow)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_flow_on_behalf_of_email(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.is_flow = true;
|
||||
s.script_path = "f/system/obo_flow".to_string();
|
||||
s.path = "f/system/obo_flow_schedule".to_string();
|
||||
});
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let tx = push_scheduled_job(&db, tx, &schedule, None, None).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
|
||||
let email = sqlx::query_scalar::<_, String>(
|
||||
"SELECT permissioned_as_email FROM v2_job j JOIN v2_job_queue q ON j.id = q.id LIMIT 1",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(email, "obo@windmill.dev");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: with retry wraps in SingleStepFlow
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_script_with_retry(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.retry = Some(serde_json::json!({
|
||||
"constant": { "attempts": 3, "seconds": 10 }
|
||||
}));
|
||||
});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let tx = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
|
||||
// When retry is set, the job kind is singlescriptflow (SingleStepFlow wraps it)
|
||||
let kind = sqlx::query_scalar::<_, String>(
|
||||
"SELECT kind::text FROM v2_job j JOIN v2_job_queue q ON j.id = q.id LIMIT 1",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(kind, "singlestepflow");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: duplicate detection (same schedule + time = skip)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_duplicate_skipped(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|_| {});
|
||||
let authed = make_authed();
|
||||
|
||||
// First push
|
||||
let tx = db.begin().await?;
|
||||
let tx = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await?;
|
||||
tx.commit().await?;
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
|
||||
// Second push with same schedule — should be idempotent
|
||||
let tx = db.begin().await?;
|
||||
let tx = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await?;
|
||||
tx.commit().await?;
|
||||
assert_eq!(count_queued_jobs(&db).await, 1); // Still 1
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: invalid timezone
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_invalid_timezone(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.timezone = "Invalid/Timezone".to_string();
|
||||
});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let result = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(count_queued_jobs(&db).await, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: invalid cron expression
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_invalid_cron(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.schedule = "not a cron".to_string();
|
||||
});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let result = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(count_queued_jobs(&db).await, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: invalid args (not a dict)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_invalid_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
let raw = serde_json::value::RawValue::from_string("[1,2,3]".to_string()).unwrap();
|
||||
s.args = Some(sqlx::types::Json(raw));
|
||||
});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let result = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(count_queued_jobs(&db).await, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: with schedule args passed to job
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_with_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
let raw =
|
||||
serde_json::value::RawValue::from_string(r#"{"key":"value"}"#.to_string()).unwrap();
|
||||
s.args = Some(sqlx::types::Json(raw));
|
||||
});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let tx = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
|
||||
let args = sqlx::query_scalar::<_, serde_json::Value>(
|
||||
"SELECT args FROM v2_job j JOIN v2_job_queue q ON j.id = q.id LIMIT 1",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(args, serde_json::json!({"key": "value"}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: script not found
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_script_not_found(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.script_path = "f/system/nonexistent".to_string();
|
||||
});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let result = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(count_queued_jobs(&db).await, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: flow not found
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_flow_not_found(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.is_flow = true;
|
||||
s.script_path = "f/system/nonexistent_flow".to_string();
|
||||
});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let result = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await;
|
||||
assert!(result.is_err());
|
||||
assert_eq!(count_queued_jobs(&db).await, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: paused schedule (paused_until in future)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_paused_schedule(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.paused_until = Some(Utc::now() + chrono::Duration::hours(1));
|
||||
});
|
||||
let authed = make_authed();
|
||||
|
||||
let tx = db.begin().await?;
|
||||
let tx = push_scheduled_job(&db, tx, &schedule, Some(&authed), None).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Job is still pushed, but scheduled_for will be after paused_until
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
|
||||
let scheduled_for = sqlx::query_scalar::<_, chrono::DateTime<Utc>>(
|
||||
"SELECT scheduled_for FROM v2_job_queue LIMIT 1",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(scheduled_for > Utc::now());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// push_scheduled_job: clock shift detection (now_cutoff >= now)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_push_clock_shift(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|_| {});
|
||||
let authed = make_authed();
|
||||
|
||||
// Pass a now_cutoff far in the future — simulates clock shift
|
||||
let future_cutoff = Utc::now() + chrono::Duration::hours(24);
|
||||
let tx = db.begin().await?;
|
||||
let tx =
|
||||
push_scheduled_job(&db, tx, &schedule, Some(&authed), Some(future_cutoff)).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
|
||||
// The scheduled_for should be after the cutoff
|
||||
let scheduled_for = sqlx::query_scalar::<_, chrono::DateTime<Utc>>(
|
||||
"SELECT scheduled_for FROM v2_job_queue LIMIT 1",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(scheduled_for > future_cutoff);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// handle_maybe_scheduled_job: disabled schedule does not push
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_handle_disabled_schedule(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.enabled = false;
|
||||
});
|
||||
let job = make_completed_job(&schedule);
|
||||
|
||||
let result = handle_maybe_scheduled_job(
|
||||
&db,
|
||||
&job,
|
||||
&schedule,
|
||||
&schedule.script_path,
|
||||
"test-workspace",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(count_queued_jobs(&db).await, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// handle_maybe_scheduled_job: script path mismatch does not push
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_handle_path_mismatch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|_| {});
|
||||
let job = make_completed_job(&schedule);
|
||||
|
||||
let result = handle_maybe_scheduled_job(
|
||||
&db,
|
||||
&job,
|
||||
&schedule,
|
||||
"f/system/different_script",
|
||||
"test-workspace",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(count_queued_jobs(&db).await, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// handle_maybe_scheduled_job: enabled + matching path pushes next job
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_handle_enabled_pushes_next_job(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|_| {});
|
||||
let job = make_completed_job(&schedule);
|
||||
|
||||
let result = handle_maybe_scheduled_job(
|
||||
&db,
|
||||
&job,
|
||||
&schedule,
|
||||
&schedule.script_path,
|
||||
"test-workspace",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
|
||||
let (_, path, trigger, trigger_kind) = get_queued_job(&db).await.unwrap();
|
||||
assert_eq!(path.as_deref(), Some("f/system/test_script"));
|
||||
assert_eq!(trigger.as_deref(), Some("f/system/test_schedule"));
|
||||
assert_eq!(trigger_kind.as_deref(), Some("schedule"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// handle_maybe_scheduled_job: on_behalf_of_email via handle path
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_handle_on_behalf_of_email(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let schedule = make_schedule(|s| {
|
||||
s.script_path = "f/system/obo_script".to_string();
|
||||
s.path = "f/system/obo_schedule".to_string();
|
||||
});
|
||||
let job = make_completed_job(&schedule);
|
||||
|
||||
let result = handle_maybe_scheduled_job(
|
||||
&db,
|
||||
&job,
|
||||
&schedule,
|
||||
&schedule.script_path,
|
||||
"test-workspace",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(count_queued_jobs(&db).await, 1);
|
||||
|
||||
let email = sqlx::query_scalar::<_, String>(
|
||||
"SELECT permissioned_as_email FROM v2_job j JOIN v2_job_queue q ON j.id = q.id LIMIT 1",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(email, "obo@windmill.dev");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// handle_maybe_scheduled_job: push failure disables schedule
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[sqlx::test(fixtures("base", "schedule_push"))]
|
||||
async fn test_handle_push_failure_disables_schedule(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
// Insert a schedule row so handle_maybe_scheduled_job can disable it
|
||||
sqlx::query(
|
||||
"INSERT INTO schedule (workspace_id, path, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, email, extra_perms, ws_error_handler_muted, no_flow_overlap)
|
||||
VALUES ('test-workspace', 'f/system/bad_schedule', 'test-user', now(), '0 0 */5 * * *', 'UTC', true, 'f/system/nonexistent', false, 'test@windmill.dev', '{}', false, false)"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let schedule = make_schedule(|s| {
|
||||
s.path = "f/system/bad_schedule".to_string();
|
||||
s.script_path = "f/system/nonexistent".to_string();
|
||||
});
|
||||
let job = make_completed_job(&schedule);
|
||||
|
||||
let result = handle_maybe_scheduled_job(
|
||||
&db,
|
||||
&job,
|
||||
&schedule,
|
||||
&schedule.script_path,
|
||||
"test-workspace",
|
||||
)
|
||||
.await;
|
||||
// Should succeed (error is handled internally by disabling schedule)
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(count_queued_jobs(&db).await, 0);
|
||||
|
||||
// Schedule should be disabled with an error
|
||||
let (enabled, error): (bool, Option<String>) = sqlx::query_as(
|
||||
"SELECT enabled, error FROM schedule WHERE workspace_id = 'test-workspace' AND path = 'f/system/bad_schedule'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(!enabled);
|
||||
assert!(error.is_some());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:pr
|
||||
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect"]
|
||||
tantivy = ["dep:windmill-indexer"]
|
||||
kafka = ["dep:rdkafka", "dep:rdkafka-sys"]
|
||||
kafka-gssapi = ["kafka", "rdkafka/gssapi"]
|
||||
nats = ["dep:async-nats", "dep:nkeys"]
|
||||
websocket = ["dep:tokio-tungstenite"]
|
||||
smtp = ["dep:mail-parser", "dep:openssl", "windmill-common/smtp"]
|
||||
@@ -170,7 +169,6 @@ tar.workspace = true
|
||||
flate2.workspace = true
|
||||
backon = {workspace = true, optional = true}
|
||||
strum = { workspace = true, optional = true }
|
||||
dashmap.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
deno_core = { workspace = true, optional = true }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,21 +4,19 @@ use crate::db::{ApiAuthed, DB};
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
use axum::routing::get;
|
||||
use axum::{
|
||||
body::Bytes, extract::Path, response::IntoResponse, routing::post, Extension, Router,
|
||||
};
|
||||
#[cfg(feature = "bedrock")]
|
||||
use axum::Json;
|
||||
use axum::{body::Bytes, extract::Path, response::IntoResponse, routing::post, Extension, Router};
|
||||
use futures::StreamExt;
|
||||
use http::{HeaderMap, Method};
|
||||
use quick_cache::sync::Cache;
|
||||
use reqwest::{Client, RequestBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, value::RawValue};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::ai_providers::{
|
||||
empty_string_as_none, AIProvider, ProviderConfig, ProviderModel,
|
||||
};
|
||||
use windmill_common::ai_providers::{empty_string_as_none, AIProvider, ProviderConfig, ProviderModel};
|
||||
use windmill_common::error::{to_anyhow, Error, Result};
|
||||
use windmill_common::utils::configure_client;
|
||||
use windmill_common::variables::get_variable_or_self;
|
||||
@@ -29,7 +27,6 @@ const AI_TIMEOUT_MAX_SECS: u64 = 86400; // 24 hours
|
||||
const AI_TIMEOUT_DEFAULT_SECS: u64 = 3600; // 1 hour
|
||||
const HTTP_POOL_MAX_IDLE_PER_HOST: usize = 10;
|
||||
const HTTP_POOL_IDLE_TIMEOUT_SECS: u64 = 90;
|
||||
const KEEPALIVE_INTERVAL_SECS: u64 = 15;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// AI request timeout in seconds.
|
||||
@@ -154,17 +151,9 @@ struct AIStandardResource {
|
||||
organization_id: Option<String>,
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
region: Option<String>,
|
||||
#[serde(
|
||||
alias = "awsAccessKeyId",
|
||||
default,
|
||||
deserialize_with = "empty_string_as_none"
|
||||
)]
|
||||
#[serde(alias = "awsAccessKeyId", default, deserialize_with = "empty_string_as_none")]
|
||||
aws_access_key_id: Option<String>,
|
||||
#[serde(
|
||||
alias = "awsSecretAccessKey",
|
||||
default,
|
||||
deserialize_with = "empty_string_as_none"
|
||||
)]
|
||||
#[serde(alias = "awsSecretAccessKey", default, deserialize_with = "empty_string_as_none")]
|
||||
aws_secret_access_key: Option<String>,
|
||||
/// Platform for Anthropic API (standard or google_vertex_ai)
|
||||
#[serde(default)]
|
||||
@@ -224,7 +213,9 @@ impl AIRequestConfig {
|
||||
let base_url = if matches!(provider, AIProvider::AWSBedrock) {
|
||||
String::new()
|
||||
} else {
|
||||
provider.get_base_url(resource.base_url, db).await?
|
||||
provider
|
||||
.get_base_url(resource.base_url, db)
|
||||
.await?
|
||||
};
|
||||
let api_key = if let Some(api_key) = resource.api_key {
|
||||
Some(get_variable_or_self(api_key, db, w_id).await?)
|
||||
@@ -347,8 +338,7 @@ impl AIRequestConfig {
|
||||
|
||||
let is_azure = provider.is_azure_openai(base_url);
|
||||
let is_anthropic = matches!(provider, AIProvider::Anthropic);
|
||||
let is_anthropic_vertex =
|
||||
is_anthropic && self.platform == AnthropicPlatform::GoogleVertexAi;
|
||||
let is_anthropic_vertex = is_anthropic && self.platform == AnthropicPlatform::GoogleVertexAi;
|
||||
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
|
||||
let is_google_ai = matches!(provider, AIProvider::GoogleAI);
|
||||
|
||||
@@ -487,9 +477,7 @@ fn transform_anthropic_for_vertex(body: &Bytes) -> Result<(String, Bytes)> {
|
||||
let model = json_body
|
||||
.remove("model")
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest("Missing 'model' field in Anthropic request".to_string())
|
||||
})?;
|
||||
.ok_or_else(|| Error::BadRequest("Missing 'model' field in Anthropic request".to_string()))?;
|
||||
|
||||
// Add anthropic_version to body (required for Vertex AI)
|
||||
json_body.insert(
|
||||
@@ -573,40 +561,6 @@ async fn check_bedrock_credentials(
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
fn is_sse_response(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get(http::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| ct.contains("text/event-stream"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn inject_keepalives<S>(
|
||||
upstream: S,
|
||||
interval: Duration,
|
||||
) -> impl futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>>
|
||||
where
|
||||
S: futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
async_stream::stream! {
|
||||
tokio::pin!(upstream);
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
chunk = upstream.next() => {
|
||||
match chunk {
|
||||
Some(item) => yield item,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(interval) => {
|
||||
yield Ok(Bytes::from(": keepalive\n\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn global_proxy(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -671,15 +625,7 @@ async fn global_proxy(
|
||||
let status_code = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let stream = response.bytes_stream();
|
||||
let body = if is_sse_response(&headers) {
|
||||
axum::body::Body::from_stream(inject_keepalives(
|
||||
stream,
|
||||
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
|
||||
))
|
||||
} else {
|
||||
axum::body::Body::from_stream(stream)
|
||||
};
|
||||
Ok((status_code, headers, body))
|
||||
Ok((status_code, headers, axum::body::Body::from_stream(stream)))
|
||||
}
|
||||
|
||||
async fn proxy(
|
||||
@@ -786,21 +732,20 @@ async fn proxy(
|
||||
#[cfg(feature = "bedrock")]
|
||||
{
|
||||
// Extract model and streaming flag for Bedrock transformation (only for POST requests)
|
||||
let (model, is_streaming) = if matches!(provider, AIProvider::AWSBedrock)
|
||||
&& method == Method::POST
|
||||
{
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct BedrockRequest {
|
||||
model: String,
|
||||
#[serde(default)]
|
||||
stream: bool,
|
||||
}
|
||||
let parsed: BedrockRequest = serde_json::from_slice(&body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?;
|
||||
(Some(parsed.model), parsed.stream)
|
||||
} else {
|
||||
(None, false)
|
||||
};
|
||||
let (model, is_streaming) =
|
||||
if matches!(provider, AIProvider::AWSBedrock) && method == Method::POST {
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct BedrockRequest {
|
||||
model: String,
|
||||
#[serde(default)]
|
||||
stream: bool,
|
||||
}
|
||||
let parsed: BedrockRequest = serde_json::from_slice(&body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?;
|
||||
(Some(parsed.model), parsed.stream)
|
||||
} else {
|
||||
(None, false)
|
||||
};
|
||||
|
||||
// For Bedrock requests, use the SDK-based approach
|
||||
if matches!(provider, AIProvider::AWSBedrock) {
|
||||
@@ -905,13 +850,5 @@ async fn proxy(
|
||||
let status_code = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let stream = response.bytes_stream();
|
||||
let body = if is_sse_response(&headers) {
|
||||
axum::body::Body::from_stream(inject_keepalives(
|
||||
stream,
|
||||
Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
|
||||
))
|
||||
} else {
|
||||
axum::body::Body::from_stream(stream)
|
||||
};
|
||||
Ok((status_code, headers, body))
|
||||
Ok((status_code, headers, axum::body::Body::from_stream(stream)))
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@ pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/hub/list", get(list_hub_apps))
|
||||
.route("/hub/get/:id", get(get_hub_app_by_id))
|
||||
.route("/hub/get_raw/:id", get(get_hub_raw_app_by_id))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Deserialize, Serialize)]
|
||||
@@ -1313,24 +1312,6 @@ pub async fn get_hub_app_by_id(
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
pub async fn get_hub_raw_app_by_id(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<Box<serde_json::value::RawValue>> {
|
||||
let value = http_get_from_hub(
|
||||
&HTTP_CLIENT,
|
||||
&format!("{}/raw_apps/{}/json", *HUB_BASE_URL.read().await, id),
|
||||
false,
|
||||
None,
|
||||
Some(&db),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
async fn delete_app(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1929,15 +1910,6 @@ async fn execute_component(
|
||||
}
|
||||
};
|
||||
|
||||
// Check rate limit for anonymous (public) executions
|
||||
if matches!(policy.execution_mode, ExecutionMode::Anonymous) && opt_authed.is_none() {
|
||||
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
|
||||
if limit > 0 {
|
||||
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execution is publisher and an user is authenticated: check if the user is authorized to
|
||||
// execute the app.
|
||||
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode, opt_authed.as_ref()) {
|
||||
|
||||
@@ -18,7 +18,6 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_assets))
|
||||
.route("/list_by_usages", post(list_assets_by_usages))
|
||||
.route("/list_favorites", get(list_favorites))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -159,7 +158,6 @@ async fn list_assets(
|
||||
'path', asset.usage_path,
|
||||
'kind', asset.usage_kind,
|
||||
'access_type', asset.usage_access_type,
|
||||
'columns', asset.columns,
|
||||
'created_at', asset.created_at,
|
||||
'metadata', (CASE
|
||||
WHEN asset.usage_kind = 'job' THEN
|
||||
@@ -184,13 +182,7 @@ async fn list_assets(
|
||||
FROM asset
|
||||
INNER JOIN asset_summary ON asset.path = asset_summary.path AND asset.kind = asset_summary.kind
|
||||
LEFT JOIN resource ON asset.kind = 'resource'
|
||||
AND (
|
||||
-- Extract base path before '?' for ?table= syntax
|
||||
CASE
|
||||
WHEN asset.path LIKE '%?%' THEN split_part(asset.path, '?', 1)
|
||||
ELSE asset.path
|
||||
END
|
||||
) = resource.path
|
||||
AND array_to_string((string_to_array(asset.path, '/'))[1:3], '/') = resource.path
|
||||
AND resource.workspace_id = $1
|
||||
LEFT JOIN v2_job job ON asset.usage_kind = 'job'
|
||||
AND asset.usage_path = job.id::text
|
||||
@@ -274,12 +266,11 @@ async fn list_assets_by_usages(
|
||||
for usage in body.usages {
|
||||
let assets = sqlx::query_scalar!(
|
||||
r#"SELECT
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
jsonb_build_object(
|
||||
'path', path,
|
||||
'kind', kind,
|
||||
'access_type', usage_access_type,
|
||||
'columns', columns
|
||||
)) as "list!: _"
|
||||
'access_type', usage_access_type
|
||||
) as "list!: _"
|
||||
FROM asset
|
||||
WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3
|
||||
ORDER BY path, kind"#,
|
||||
@@ -293,29 +284,3 @@ async fn list_assets_by_usages(
|
||||
}
|
||||
Ok(Json(assets_vec))
|
||||
}
|
||||
|
||||
async fn list_favorites(
|
||||
authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<Vec<Value>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let favorites = sqlx::query_scalar!(
|
||||
r#"SELECT
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'path', favorite.path
|
||||
)) as "favorite_asset!: _"
|
||||
FROM favorite
|
||||
WHERE favorite.workspace_id = $1
|
||||
AND favorite.usr = $2
|
||||
AND favorite_kind = 'asset'
|
||||
"#,
|
||||
&w_id,
|
||||
&authed.username
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Ok(Json(favorites))
|
||||
}
|
||||
|
||||
@@ -64,19 +64,6 @@ lazy_static::lazy_static! {
|
||||
(20260126235947, include_str!(
|
||||
"../../custom_migrations/lowercase_emails_safe.sql"
|
||||
).to_string()),
|
||||
(20260206000000, "".to_string()),
|
||||
(20260207000001, include_str!(
|
||||
"../../migrations/20260207000001_concurrent_indexes_v2_job.up.sql"
|
||||
).to_string()),
|
||||
(20260207000002, include_str!(
|
||||
"../../migrations/20260207000002_concurrent_indexes_v2_job_completed.up.sql"
|
||||
).to_string()),
|
||||
(20260207000003, include_str!(
|
||||
"../../migrations/20260207000003_concurrent_indexes_v2_job_queue.up.sql"
|
||||
).to_string()),
|
||||
(20260207000004, include_str!(
|
||||
"../../migrations/20260207000004_concurrent_indexes_other.up.sql"
|
||||
).to_string()),
|
||||
].into_iter().collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ pub enum FavoriteKind {
|
||||
App,
|
||||
#[allow(non_camel_case_types)]
|
||||
Raw_App,
|
||||
Asset,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct Favorite {
|
||||
|
||||
@@ -473,14 +473,12 @@ async fn create_flow(
|
||||
workspace_id, path, summary, description,
|
||||
dependency_job, lock_error_logs, draft_only, tag,
|
||||
dedicated_worker, visible_to_runner_only, on_behalf_of_email,
|
||||
ws_error_handler_muted,
|
||||
value, schema, edited_by, edited_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
NULL, '', $5, $6,
|
||||
$7, $8, $9,
|
||||
$10,
|
||||
$11, $12::text::json, $13, now()
|
||||
$10, $11::text::json, $12, now()
|
||||
)"#,
|
||||
w_id,
|
||||
nf.path,
|
||||
@@ -491,7 +489,6 @@ async fn create_flow(
|
||||
nf.dedicated_worker,
|
||||
nf.visible_to_runner_only.unwrap_or(false),
|
||||
nf.on_behalf_of_email.and(Some(&authed.email)),
|
||||
nf.ws_error_handler_muted.unwrap_or(false),
|
||||
sqlx::types::Json(&nf.value) as _,
|
||||
schema_str,
|
||||
&authed.username,
|
||||
@@ -900,13 +897,12 @@ async fn update_flow(
|
||||
dedicated_worker = $5,
|
||||
visible_to_runner_only = $6,
|
||||
on_behalf_of_email = $7,
|
||||
ws_error_handler_muted = $8,
|
||||
value = $9,
|
||||
schema = $10::text::json,
|
||||
edited_by = $11,
|
||||
value = $8,
|
||||
schema = $9::text::json,
|
||||
edited_by = $10,
|
||||
edited_at = now()
|
||||
WHERE
|
||||
path = $12 AND workspace_id = $13",
|
||||
path = $11 AND workspace_id = $12",
|
||||
if is_new_path { flow_path } else { &nf.path },
|
||||
nf.summary,
|
||||
nf.description.as_deref().unwrap_or(""),
|
||||
@@ -914,7 +910,6 @@ async fn update_flow(
|
||||
nf.dedicated_worker,
|
||||
nf.visible_to_runner_only.unwrap_or(false),
|
||||
nf.on_behalf_of_email.and(Some(&authed.email)),
|
||||
nf.ws_error_handler_muted.unwrap_or(false),
|
||||
sqlx::types::Json(&nf.value) as _,
|
||||
schema_str,
|
||||
authed.username,
|
||||
|
||||
@@ -6378,18 +6378,10 @@ fn register_potential_assets_on_inline_execution(
|
||||
match assets {
|
||||
Some(Ok(assets)) => {
|
||||
for asset in assets {
|
||||
let columns = asset.columns.as_ref().map(|cols| {
|
||||
cols.iter()
|
||||
.map(|(col_name, col_access_type)| {
|
||||
(col_name.clone(), (*col_access_type).into())
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
register_runtime_asset(InsertRuntimeAssetParams {
|
||||
access_type: asset.access_type.map(|a| a.into()),
|
||||
asset_kind: asset.kind.into(),
|
||||
asset_path: asset.path,
|
||||
columns,
|
||||
job_id,
|
||||
workspace_id: w_id.to_string(),
|
||||
created_at: None,
|
||||
|
||||
@@ -167,7 +167,6 @@ mod teams_approvals_oss;
|
||||
#[cfg(feature = "native_trigger")]
|
||||
pub mod native_triggers;
|
||||
mod public_app_layer;
|
||||
mod public_app_rate_limit;
|
||||
mod static_assets;
|
||||
#[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))]
|
||||
pub mod stripe_ee;
|
||||
|
||||
@@ -18,6 +18,13 @@ pub async fn custom_migrations(migrator: &mut CustomMigrator, db: &DB) -> Result
|
||||
tracing::error!("Could not apply flow versioning fix migration: {err:#}");
|
||||
}
|
||||
|
||||
let db2 = db.clone();
|
||||
let _ = tokio::task::spawn(async move {
|
||||
if let Err(err) = fix_job_completed_index(&db2).await {
|
||||
tracing::error!("Could not apply job completed index fix migration: {err:#}");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -68,3 +75,376 @@ async fn fix_flow_versioning_migration(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn has_done_migration(db: &DB, migration_job_name: &str) -> bool {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT name FROM windmill_migrations WHERE name = $1)",
|
||||
migration_job_name
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
use sqlx::Pool;
|
||||
|
||||
macro_rules! run_windmill_migration {
|
||||
($migration_job_name:expr, $db:expr, |$tx:ident| $code:block) => {
|
||||
{
|
||||
let migration_job_name = $migration_job_name;
|
||||
let db: &Pool<Postgres> = $db;
|
||||
|
||||
let has_done = has_done_migration(db, migration_job_name).await;
|
||||
if !has_done {
|
||||
tracing::info!("Applying {migration_job_name} migration");
|
||||
let mut $tx = db.begin().await?;
|
||||
let mut r = false;
|
||||
while !r {
|
||||
r = sqlx::query_scalar!("SELECT pg_try_advisory_lock(4242)")
|
||||
.fetch_one(&mut *$tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error acquiring {migration_job_name} lock: {e:#}");
|
||||
sqlx::migrate::MigrateError::Execute(e)
|
||||
})?
|
||||
.unwrap_or(false);
|
||||
|
||||
if !r {
|
||||
tracing::info!("PG {migration_job_name} lock already acquired by another server or worker, retrying in 5s. (look for the advisory lock in pg_lock with granted = true)");
|
||||
drop($tx);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
$tx = db.begin().await?;
|
||||
}
|
||||
}
|
||||
tracing::info!("acquired lock for {migration_job_name}");
|
||||
|
||||
let has_done = has_done_migration(db, migration_job_name).await;
|
||||
|
||||
if !has_done {
|
||||
|
||||
$code
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO windmill_migrations (name) VALUES ($1) ON CONFLICT DO NOTHING",
|
||||
migration_job_name
|
||||
)
|
||||
.execute(&mut *$tx)
|
||||
.await?;
|
||||
tracing::info!("Finished applying {migration_job_name} migration");
|
||||
} else {
|
||||
tracing::debug!("migration {migration_job_name} already done");
|
||||
}
|
||||
|
||||
let _ = sqlx::query("SELECT pg_advisory_unlock(4242)")
|
||||
.execute(&mut *$tx)
|
||||
.await?;
|
||||
$tx.commit().await?;
|
||||
tracing::info!("released lock for {migration_job_name}");
|
||||
} else {
|
||||
tracing::debug!("migration {migration_job_name} already done");
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async fn fix_job_completed_index(db: &DB) -> Result<(), Error> {
|
||||
// let has_done_migration = sqlx::query_scalar!(
|
||||
// "SELECT EXISTS(SELECT name FROM windmill_migrations WHERE name = 'fix_job_completed_index')"
|
||||
// )
|
||||
// .fetch_one(db)
|
||||
// .await?
|
||||
// .unwrap_or(false);
|
||||
// if !has_done_migration {
|
||||
// tracing::info!("Applying fix_job_completed_index migration");
|
||||
// let mut tx = db.begin().await?;
|
||||
// let mut r = false;
|
||||
// while !r {
|
||||
// r = sqlx::query_scalar!("SELECT pg_try_advisory_lock(4242)")
|
||||
// .fetch_one(&mut *tx)
|
||||
// .await
|
||||
// .map_err(|e| {
|
||||
// tracing::error!("Error acquiring fix_job_completed_index lock: {e:#}");
|
||||
// sqlx::migrate::MigrateError::Execute(e)
|
||||
// })?
|
||||
// .unwrap_or(false);
|
||||
// if !r {
|
||||
// tracing::info!("PG fix_job_completed_index_migration lock already acquired by another server or worker, retrying in 5s. (look for the advisory lock in pg_lock with granted = true)");
|
||||
// tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
// }
|
||||
// }
|
||||
// // sqlx::query(
|
||||
// // "CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_completed_job_workspace_id_created_at_new ON completed_job (workspace_id, job_kind, is_skipped, is_flow_step, created_at DESC, started_at DESC)"
|
||||
// // ).execute(db).await?;
|
||||
|
||||
// sqlx::query("DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_created_at")
|
||||
// .execute(db)
|
||||
// .await?;
|
||||
|
||||
// sqlx::query!("INSERT INTO windmill_migrations (name) VALUES ('fix_job_completed_index') ON CONFLICT DO NOTHING")
|
||||
// .execute(&mut *tx)
|
||||
// .await?;
|
||||
// let _ = sqlx::query("SELECT pg_advisory_unlock(4242)")
|
||||
// .execute(&mut *tx)
|
||||
// .await?;
|
||||
// tx.commit().await?;
|
||||
// }
|
||||
|
||||
run_windmill_migration!("fix_job_completed_index_2", &db, |tx| {
|
||||
// sqlx::query(
|
||||
// "CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_completed_job_workspace_id_created_at_new_2 ON completed_job (workspace_id, job_kind, success, is_skipped, is_flow_step, created_at DESC)"
|
||||
// ).execute(db).await?;
|
||||
|
||||
// sqlx::query(
|
||||
// "CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_completed_job_workspace_id_started_at_new ON completed_job (workspace_id, job_kind, success, is_skipped, is_flow_step, started_at DESC)"
|
||||
// ).execute(db).await?;
|
||||
|
||||
sqlx::query("DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_created_at")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_created_at_new",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("fix_job_completed_index_3", &db, |tx| {
|
||||
sqlx::query("DROP INDEX CONCURRENTLY IF EXISTS index_completed_job_on_schedule_path")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DROP INDEX CONCURRENTLY IF EXISTS concurrency_limit_stats_queue")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DROP INDEX CONCURRENTLY IF EXISTS root_job_index")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
sqlx::query("DROP INDEX CONCURRENTLY IF EXISTS index_completed_on_created")
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("fix_job_index_1_II", &db, |tx| {
|
||||
let migration_job_name = "fix_job_index_1_II";
|
||||
let mut i = 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
sqlx::query!("create index concurrently if not exists ix_job_workspace_id_created_at_new_3 ON v2_job (workspace_id, created_at DESC)")
|
||||
.execute(db)
|
||||
.await?;
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query!("create index concurrently if not exists ix_job_workspace_id_created_at_new_8 ON v2_job (workspace_id, created_at DESC) where kind in ('deploymentcallback') AND parent_job IS NULL")
|
||||
.execute(db)
|
||||
.await?;
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query!("create index concurrently if not exists ix_job_workspace_id_created_at_new_9 ON v2_job (workspace_id, created_at DESC) where kind in ('dependencies', 'flowdependencies', 'appdependencies') AND parent_job IS NULL")
|
||||
.execute(db)
|
||||
.await?;
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query!("create index concurrently if not exists ix_job_workspace_id_created_at_new_5 ON v2_job (workspace_id, created_at DESC) where kind in ('preview', 'flowpreview') AND parent_job IS NULL")
|
||||
.execute(db)
|
||||
.await?;
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS root_job_index_by_path_2")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query(
|
||||
"DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_created_at_new_2",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query(
|
||||
"DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_started_at_new",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query("DROP INDEX CONCURRENTLY IF EXISTS root_job_index_by_path")
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("fix_labeled_jobs_index", &db, |tx| {
|
||||
tracing::info!("Special migration to add index concurrently on job labels 2");
|
||||
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS labeled_jobs_on_jobs")
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"CREATE INDEX CONCURRENTLY labeled_jobs_on_jobs ON v2_job_completed USING GIN ((result -> 'wm_labels')) WHERE result ? 'wm_labels'"
|
||||
).execute(db).await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("v2_labeled_jobs_index", &db, |tx| {
|
||||
tracing::info!("Special migration to add index concurrently on job labels");
|
||||
sqlx::query!(
|
||||
"CREATE INDEX CONCURRENTLY ix_v2_job_labels ON v2_job
|
||||
USING GIN (labels)
|
||||
WHERE labels IS NOT NULL"
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("v2_jobs_rls", &db, |tx| {
|
||||
sqlx::query!("ALTER TABLE v2_job ENABLE ROW LEVEL SECURITY")
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("v2_improve_v2_job_indices_ii", &db, |tx| {
|
||||
sqlx::query!("create index concurrently if not exists ix_v2_job_workspace_id_created_at ON v2_job (workspace_id, created_at DESC) where kind in ('script', 'flow', 'singlestepflow') AND parent_job IS NULL")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS ix_job_workspace_id_created_at_new_6")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS ix_job_workspace_id_created_at_new_7")
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("v2_improve_v2_queued_jobs_indices", &db, |tx| {
|
||||
sqlx::query!("CREATE INDEX CONCURRENTLY IF NOT EXISTS queue_sort_v2 ON v2_job_queue (priority DESC NULLS LAST, scheduled_for, tag) WHERE running = false")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
// sqlx::query!("CREATE INDEX CONCURRENTLY queue_sort_2_v2 ON v2_job_queue (tag, priority DESC NULLS LAST, scheduled_for) WHERE running = false")
|
||||
// .execute(db)
|
||||
// .await?;
|
||||
|
||||
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS queue_sort")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS queue_sort_2")
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("audit_timestamps", db, |tx| {
|
||||
sqlx::query!(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_audit_timestamps ON audit (timestamp DESC)"
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("job_completed_completed_at", db, |tx| {
|
||||
sqlx::query!(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_job_completed_completed_at ON v2_job_completed (completed_at DESC)"
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("alerts_by_workspace", db, |tx| {
|
||||
sqlx::query!(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS alerts_by_workspace ON alerts (workspace_id);"
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("remove_redundant_log_file_index", db, |tx| {
|
||||
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS log_file_hostname_log_ts_idx")
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("v2_job_queue_suspend", db, |tx| {
|
||||
sqlx::query!(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS v2_job_queue_suspend ON v2_job_queue (workspace_id, suspend) WHERE suspend > 0;"
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("audit_recent_login_activities", db, |tx| {
|
||||
sqlx::query!(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_audit_recent_login_activities
|
||||
ON audit (timestamp, username)
|
||||
WHERE operation IN ('users.login', 'oauth.login', 'users.token.refresh');"
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("v2_script_lock_index", db, |tx| {
|
||||
sqlx::query!(
|
||||
"CREATE INDEX CONCURRENTLY IF NOT EXISTS script_not_archived ON script (workspace_id, path, created_at DESC) where archived = false;"
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
});
|
||||
|
||||
run_windmill_migration!("v2_job_completed_completed_at_9", db, |tx| {
|
||||
let migration_job_name = "v2_job_completed_completed_at";
|
||||
let mut i = 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
sqlx::query!("create index concurrently if not exists ix_job_workspace_id_completed_at_all ON v2_job_completed (workspace_id, completed_at DESC)")
|
||||
.execute(db)
|
||||
.await?;
|
||||
i += 1;
|
||||
|
||||
sqlx::query!("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_job_v2_job_root_by_path_2 ON v2_job (workspace_id, runnable_path) WHERE parent_job IS NULL;")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query!("create index concurrently if not exists ix_job_root_job_index_by_path_2 ON v2_job (workspace_id, runnable_path, created_at desc) WHERE parent_job IS NULL")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query!(
|
||||
"DROP INDEX CONCURRENTLY IF EXISTS ix_completed_job_workspace_id_started_at_new_2"
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
|
||||
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS ix_job_created_at")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
i += 1;
|
||||
|
||||
sqlx::query!("DROP INDEX CONCURRENTLY IF EXISTS ix_v2_job_root_by_path")
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
i += 1;
|
||||
tracing::info!("step {i} of {migration_job_name} migration");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user