Author SHA1 Message Date
james.maes a20d36c108 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#30) from deps/align-gitea into develop
build / maven (push) Successful in 10m19s
maven / build (push) Successful in 3m35s
2026-11-07 18:09:56 +00:00
james.maesandClaude Opus 5.5 a992133053 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Successful in 2m35s
maven / build (push) Successful in 2m36s
pom.xml: parent -> 2.2.1
pom.xml: qqq.snapshot.version: 4.1.0-RC.8 -> 4.1.0-RC.26
pom.xml: qqq.snapshot.version: 4.1.0-RC.7 -> 4.1.0-RC.26
pom.xml: qqq-frontend-next: 1.0.0-RC.19 -> 1.0.0-RC.38
pom.xml: removed Central snapshot repository
pom.xml: added qqq.version=4.1.0-RC.26 (parent BOM import)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:03:50 +00:00
james.maes 4b3ca697e5 Merge pull request 'ci: pin shared workflow to v1' (#29) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 13s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
QRun-IO/ci-policy: native / native-result (push) Successful in 17s
build / maven (push) Successful in 3m47s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 3m47s
2026-11-06 16:08:17 +00:00
james.maesandClaude Opus 5.5 1a800da73d ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
build / maven (push) Successful in 3m47s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 3m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 14:10:39 +00:00
james.maes 17a094d427 Merge pull request 'ci: use shared ci-workflows Maven workflow' (#28) from ci/ci-workflows into develop
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
build / maven (push) Successful in 1m50s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow d20a578c7d818bc7ca32a2ac2c675da4e4f27385] (push) Successful in 5s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 1m50s
2026-11-05 23:16:58 +00:00
james.maesandClaude Opus 5.5 009e940ee6 ci: use shared ci-workflows Maven workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
build / maven (push) Successful in 2m11s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m22s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 3s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Failing after 7m36s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 1s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 2m11s
Replace the platform-ci release callers with one caller of
KofTwentyTwo/ci-workflows maven.yaml (qqq-orb port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:05:49 +00:00
forge-recovery b6c73cc3a5 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / ci-result (push) Successful in 4s
Shared Java Maven CI / verify (push) Successful in 1m47s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m19s
qrun-release / postpublish (push) Failing after 10m30s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 35s
QRun-IO/ci-policy: native / native-result (push) Successful in 10s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 36s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
Shared Java Maven CI / gate (push) Successful in 16s
qrun-release / release (push) Successful in 46m25s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 10s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 46m21s
Shared QRun source version / version (push) Failing after 10m30s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
2026-11-02 22:17:18 +00:00
forge-recovery 5d4b212b56 ci: enroll shared Gitea lifecycle
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 28s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 7s
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 1m39s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 10s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 24s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 14m29s
2026-11-02 21:33:35 +00:00
forge-recoveryandCodex 1828c7f9d8 ci: enroll shared Gitea release-candidate lifecycle
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 18s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m29s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 59m44s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 5s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 20:17:34 +00:00
055e33c99b ci: add the isolated postpublish job to the QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
Shared Java Maven CI / verify (push) Canceled after 0s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 1m4s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 3h0m4s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 3s
Release candidate / release-rc (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 7s
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h36m39s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:49:57 +00:00
912f1dc3a9 ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI / verify (push) Canceled after 0s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
Shared Java Maven CI / secrets (push) Successful in 31s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 29s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m35s
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Canceled after 51m49s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
Shared Java Maven CI / gate (push) Canceled after 0s
qrun-release / release (push) Canceled after 52m3s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:18:44 +00:00
forge-recoveryandCodex cd995254fd ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 7s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Failing after 1s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 12s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Failing after 2m23s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:12:47 +00:00
james.maesandCodex da332ce0fb ci: adopt managed orb 0.8 patch track (#22)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 15:28:56 -05:00
james.maesandCodex 22a40ad295 ci: adopt shared orb 0.7.18 (#21)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:46:50 -05:00
james.maesandCodex c92df228b9 ci: adopt shared orb 0.7.17 (#20)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 14:16:33 -05:00
james.maesandCodex 5af79ae05d ci: use the shared branch-aware CodeQL worker (#18)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:46:11 -05:00
james.maesandCodex 469e727884 ci: adopt central orb 0.7.12 and stable gates (#16)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:52:58 -05:00
james.maesandCodex 6434453666 ci: centralize rc release completion in qqq orb (#14)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:24:27 -05:00
james.maesandCodex f2584be8c5 ci: adopt shared orb 0.7.11 (#13)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:09 -05:00
james.maes 43d65e2e58 Merge pull request #12 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:20:54 -05:00
james.maesandCodex 6dbbdacd0e ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:08:18 -05:00
james.maes 913bd497ed Merge pull request #11 from QRun-IO/feature/jobs-app-permission-gate
fix(security): gate the Jobs app and home widgets behind jobs.hasAccess
2026-10-16 10:19:12 -05:00
james.maesandClaude Opus 5.5 8cb487343a fix(security): gate the Jobs app and home widgets behind <appName>.hasAccess
The Jobs app and five of its six home widgets had no permission rules, so
the nav entry and home rendered for users with no permissions. They now
require <appName>.hasAccess (default jobs.hasAccess, exposed via
JobsQBitConfig.getHasAccessPermissionName()) with HIDDEN deny behaviour.
Workers keeps its jobs_admin rule. Record widgets, the jobs REST/UI API and
jobs_job record locks are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:06:34 -05:00
james.maes b8bc1a7719 Merge pull request #10 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:57 -05:00
james.maesandClaude Opus 5.5 dd06743824 ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:21:14 -05:00
james.maes 7a1f236c45 Merge pull request #9 from QRun-IO/feature/job-progress
feat(progress): job log panel, live phase/percent, log retention, parent app
2026-10-14 21:15:10 -05:00
james.maes 80b0b66c49 Merge remote-tracking branch 'origin/develop' into feature/job-progress
# Conflicts:
#	README.md
#	docs/DESIGN.md
#	pom.xml
#	src/main/java/com/kingsrook/qbits/jobs/JobsCoreContract.java
#	src/main/java/com/kingsrook/qbits/jobs/widgets/JobsWidgetRenderer.java
#	src/test/java/com/kingsrook/qbits/jobs/JobsRetentionTest.java
2026-10-14 21:12:47 -05:00
james.maesandClaude Opus 5.5 e5c6a71493 feat(progress): job log panel, live phase and percent, log retention, parent app
- Log child list on the job record (newest first), when core defines
  jobs_log
- Running jobs and the Live Progress panel show the current phase,
  percent, message, bytes and ETA from the new jobs_job progress fields,
  falling back to the rows/chunks derivation
- Retention purges jobs_log with its job
- parentAppName config places the Jobs app under a host group (for
  example Admin > Operations)

Needs QQQ 4.1.0-RC.8 (QRun-IO/qqq#1212).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:44:16 -05:00
james.maes 0378647bce Merge pull request #8 from QRun-IO/fix/version-assertion
fix(test): accept release versions in the producer metadata assertion
2026-10-14 16:56:12 -05:00
james.maesandClaude Opus 5.5 8ffd6d21ab fix(test): accept release versions in the producer metadata assertion
The test pinned the qbit version to 0.1.0-SNAPSHOT, so the release-candidate
build (where the orb sets 0.1.0-RC.1) failed at JobsQBitProducerTest:119 and
0.1.0-RC.1 could not publish. Assert the version shape instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:49:36 -05:00
james.maes 21fb47f74a Merge pull request #7 from QRun-IO/feature/core-rc7
feat: built on QQQ 4.1.0-RC.7; stand-ins removed
2026-10-14 16:43:56 -05:00
james.maesandClaude Opus 5.5 f961191173 feat: built on QQQ 4.1.0-RC.7; stand-ins removed
RC.7 ships the durable jobs kernel (QRun-IO/qqq #1178, #1181, #1184, #1191,
#1195, #1201).
- Both profiles (default and demo) use 4.1.0-RC.7. The demo no longer needs a
  locally installed kernel build.
- JobsCoreContract references core's tables, enums, labels, alert names and
  joins. JobsSecurity is removed and callers use core's DurableJobsSecurity.
- JobsStorage calls StorageAction.deleteObject directly.
- Tests build the instance with core's DurableJobsMetaDataProvider (real
  record locks, joins and possible values). JobsTablesStandIn is removed.
- The Postgres DDL matches core RC.7's seven tables. The docs require RC.7
  or later.

verify: 37 tests, checkstyle clean, JaCoCo 99% instructions / 100% classes.
The demo compiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:33:32 -05:00
james.maes 8506770cbc Merge pull request #6 from QRun-IO/feature/orb-0.6.9
ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
2026-10-14 10:46:10 -05:00
james.maesandClaude Opus 5.5 6a1f98aef4 ci: qqq-orb 0.6.9 (refresh SNAPSHOT dependencies)
Moves qqq-orb@0.6.8 to qqq-orb@0.6.9, which passes --update-snapshots so cached SNAPSHOT dependencies are re-checked on every build (QRun-IO/qqq-orb#22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:07:15 -05:00
james.maes eb93f00859 Merge pull request #3 from QRun-IO/fix/standin-remove-by-rc7
chore: stand-ins are removed by RC.7 (RC.6 shipped without the jobs kernel)
2026-10-14 09:06:50 -05:00
james.maesandClaude Opus 5.5 73538809f3 chore: remaining RC.6 references say RC.7 (storage delete, DDL tag)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:30:10 -05:00
james.maesandClaude Opus 5.5 b9480a2f56 chore: stand-ins are removed by RC.7 (RC.6 shipped without the jobs kernel)
QQQ 4.1.0-RC.6 was released without the durable jobs kernel (#1178, #1181,
#1191). The stand-ins now say removeBy=RC.7, and the README and DESIGN say
RC.7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:29:37 -05:00
james.maes bf2322f438 Merge pull request #2 from QRun-IO/feature/job-effects
feat: job effects in the Jobs app (outbox review, counts, retention)
2026-10-14 08:27:36 -05:00
james.maes d6465a8306 Merge pull request #1 from QRun-IO/feature/scaffold-and-app
feat: Jobs app for QQQ durable background jobs
2026-10-14 08:27:24 -05:00
james.maesandClaude Opus 5.5 dfa9ca5e37 feat: job effects in the Jobs app (outbox review, counts, retention)
The app side of QRun-IO/qqq#1191: core's transactional outbox for side effects
outside the job's database (jobs_effect).
- The job record gets an Effects child list (when core has the table).
- Needs attention shows "N effects need review"; Live Progress gets an Effects
  group (sent, pending, failed, needs review).
- Declares the alert type jobs.effect-unresolved ("Job effect needs review").
- Retention deletes a purged job's effects and keeps jobs whose effects are
  still outstanding or need review, with a summary line.
- Adds jobs_effect to the Data Analysis list and to the Postgres DDL.
- Stand-ins tagged STAND-IN(core=QRun-IO/qqq#1191, removeBy=RC.6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:55:33 -05:00
29 changed files with 1409 additions and 716 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+7
View File
@@ -0,0 +1,7 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+13 -22
View File
@@ -6,7 +6,7 @@ This file provides guidance to Codex when working with this repository.
QBit Jobs is the typed Jobs dashboard, record panels, alert declarations and retention
process for QQQ durable jobs. Read `docs/DESIGN.md` and `README.md` first. Core owns
all six `jobs_*` tables and control actions. This qbit adds no tables and no client UI.
all seven `jobs_*` tables and control actions. This qbit adds no tables and no client UI.
## Build commands
@@ -27,17 +27,17 @@ and the memory backend. No Docker or external services. Do not commit in this sa
## Architecture by package
- `com.kingsrook.qbits.jobs`: `JobsQBitProducer` registers the app and metadata directly;
`JobsQBitConfig` supplies validated host settings; `JobsCoreContract` holds temporary
core identifiers; `JobsSecurity` derives owner/admin session keys; `JobsIntegrations`
`JobsQBitConfig` supplies validated host settings; `JobsCoreContract` references core identifiers
and field names; core `DurableJobsSecurity` derives owner/admin session keys; `JobsIntegrations`
supplies opt-in ESB, Quick Search and Data Analysis table lists.
- `widgets`: `JobsWidgetRenderer` dispatches the eight home/record panels;
`JobsChartData` provides typed stacked bars; `JobsChildRecordListRenderer` ensures
session keys before delegating to core's child-list renderer.
- `processes`: `JobsPurgeFinishedStep` performs retention with 200-row keyset batches;
`JobsStorage` isolates reflective core storage/metadata compatibility.
- Tests: `BaseJobsTest`, `JobsTablesStandIn`, `JobsAlertsValidationStandIn`, plus producer,
renderer, security and retention suites. Test storage emulates the future deletion API
without filesystem access; the real RC.5 adapter's skip behavior is tested separately.
`JobsStorage` uses core storage actions and durable jobs metadata.
- Tests: `BaseJobsTest` uses core `DurableJobsMetaDataProvider` and `DurableJobsMetaData`,
plus producer, renderer, security and retention suites. Memory storage verifies real
object deletion; a recording storage seam exercises failures and retries.
## Invariants
@@ -64,25 +64,16 @@ and the memory backend. No Docker or external services. Do not commit in this sa
every class/method, fluent `withX` setters and the Kingsrook Apache license header.
- Enforce the repository's `checkstyle/config.xml`; do not weaken gates to pass builds.
## Dependencies and stand-ins
## Dependencies
Parent `com.kingsrook:qbit-build-parent:2.0.0`; default `qqq-snapshot` profile imports
released core BOM `4.1.0-RC.5`. Main code and default tests must not import `core.jobs`.
The opt-in `demo` profile selects locally installed `4.1.0-SNAPSHOT`, adds `src/demo/java`
and test RDBMS/Javalin/H2 dependencies. No demo server is provided yet.
Remove stand-ins when RC.6 supplies the contracts:
- `JobsCoreContract`, `JobsSecurity`, test `JobsTablesStandIn`: core #1178.
- `JobsStorage`: #1173 deleteObject method and #1178 durableJobs metadata getter.
- Test `JobsAlertsValidationStandIn`: #1178 kernel identifiers use underscores rejected
by the released alert-name validator. Only those two errors are waived in tests;
production validation remains strict. A core validator fix is required for startup.
Parent `com.kingsrook:qbit-build-parent:2.0.0`; QQQ 4.1.0-RC.7 or later is required.
The default `qqq-snapshot` profile imports released core BOM `4.1.0-RC.7`.
The opt-in `demo` profile uses the same release, adds `src/demo/java` and test
RDBMS/Javalin/H2 dependencies for the demo server.
## Release flow
CircleCI uses `kingsrook/qqq-orb@0.6.8`: feature branches test; `develop` publishes
SNAPSHOTs; `release/*` publishes RCs; `v*` tags publish stable releases. The GitHub
release-candidate workflow follows the repository standard. Publishing requires explicit
owner authorization. Update CHANGELOG and resolve the RC.6 compatibility requirements
before a release; do not change another repository from this workspace.
owner authorization. Update CHANGELOG before a release; do not change another repository from this workspace.
+36 -3
View File
@@ -5,17 +5,50 @@ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - Unreleased
### Security
- The Jobs app and its five non-admin home widgets now require `<appName>.hasAccess`
(default `jobs.hasAccess`) and are hidden otherwise. Behaviour change: hosts must grant
this permission to the users who should see the Jobs app. Workers still needs `jobs_admin`.
### Changed
- Built on QQQ 4.1.0-RC.7; stand-ins removed.
### Added
- RC.8 optional job progress fields: phase labels, explicit percent with legacy fallback,
message, binary byte counts, ETA and dropped-log notice in typed widgets.
- Full-width owner-scoped Log child list after Live Progress, ascending by log ID.
- System-session retention of log lines, including 64-bit cursors and 200-row batches.
- Host `parentAppName` configuration for Admin > Operations > Jobs placement.
- Progress/log, owner isolation, real core log permission and navigation tests.
### Changed
- Default QQQ BOM from 4.1.0-RC.5 to 4.1.0-RC.8; existing `qqq.snapshot.version`
override verifies against locally installed 4.1.0-SNAPSHOT without the demo profile.
- Storage adapter test now verifies failure/reference preservation with the current API.
### Earlier additions
- Optional effects outbox child list, viewer-scoped attention review counts and typed
Live Progress effect statistics; Data Analysis includes `jobs_effect`.
- `jobs.effect-unresolved` warning alert for effects requiring outcome review.
- Retention deletes settled effects and preserves jobs and inputs with outstanding
effects, reporting the number kept in the process summary.
- Outbox Postgres table and indexes, and coverage for effect counts, owner isolation
and retention.
- Jobs app with six typed home widgets for live progress, throughput, attention,
worker health and 30-day history, scoped to each viewer's record permissions.
- Job progress, ordered chunk/error/event child lists, and filtered audit links.
- Core-compatible failure/stall alert declarations and a reaper heartbeat monitor.
- Configurable, hidden, scheduled retention with system-session isolation, paged
deletion, object cleanup compatibility and standard process summary lines.
deletion, object cleanup and standard process summary lines.
- Opt-in ESB, Quick Search and Data Analysis registration lists.
- Postgres DDL for the six core tables and indexes.
- Released RC.5 contract/security/storage stand-ins and memory-backed tests.
- Postgres DDL for the core tables and indexes.
- Memory-backed tests using core durable jobs metadata and storage actions.
- Offline Maven wrapper build with checkstyle and JaCoCo coverage enforcement.
- Readable home widgets:
- block cells with record links and progress bars;
+39 -22
View File
@@ -2,13 +2,13 @@
The Jobs app for QQQ durable background jobs. Its home dashboard shows live progress, throughput,
failures, workers and 30 days of history. It adds progress panels and child lists on the job record,
declares the failure and stall alert types, and purges old jobs. The durable job kernel itself
declares the failure, stall and effect-review alert types, and purges old jobs. The durable job kernel itself
(tables, leases, plans, control actions, uploads) is in QQQ core. Design: [docs/DESIGN.md](docs/DESIGN.md),
from QRun-IO/qqq `docs/specs/DURABLE-JOBS-AND-ASSISTED-BULK-LOAD.md`.
## Requirements
- Java 21.
- QQQ 4.1 with durable jobs enabled: `DurableJobsMetaDataProvider.defineAll(...)` and
- QQQ 4.1.0-RC.7 or later with durable jobs enabled: `DurableJobsMetaDataProvider.defineAll(...)` and
`DurableJobsMetaData` on the instance.
- A storage table for chunk objects and uploads, which should be object storage (S3).
@@ -34,12 +34,17 @@ The tables are core's, prefixed `jobs_`. This qbit adds no tables.
| `jobs_job` | One row per job: status, progress counters, owner, channel, inputs, outputs. **Nav entry.** |
| `jobs_chunk` | The leased units of work, with attempts, checkpoints and per-chunk counts. Child list on the job. |
| `jobs_row_error` | Which rows failed and why. Child list on the job; reportable. |
| `jobs_effect` | Side effects outside the database (outbox): email, HTTP, S3, other databases; child list on the job. |
| `jobs_log` | Append-only, owner-scoped log lines (level, time, phase, message); optional child list on the job. |
| `jobs_event` | The job's timeline (launched, reclaimed, paused, completed and so on). Child list on the job. |
| `jobs_worker` | The pods that run jobs. |
| `jobs_upload` | Uploaded files (refs, owners, expiry). |
## Permissions
- Users see their own jobs: core's record lock on `jobs_job.userId` through the `jobs_user` key.
- The Jobs app and its home widgets require `<appName>.hasAccess` (default `jobs.hasAccess`, from
`JobsQBitConfig.getHasAccessPermissionName()`); without it they are hidden. Hosts must grant it to the
users who should see the Jobs app. The header Jobs tray and record locks are unaffected.
- `jobs_admin` sees and controls every job, and sees the Workers widget.
- Apply the keys in your auth customizer with `DurableJobsSecurity.applyToSession(session)`.
@@ -51,7 +56,17 @@ The tables are core's, prefixed `jobs_`. This qbit adds no tables.
- `schedulerName` (null; set to a host scheduler name to enable the schedule);
- `homeRefreshSeconds` (5);
- `recordRefreshSeconds` (3);
- `appName` (`jobs`).
- `appName` (`jobs`);
- `parentAppName` (null; an existing host app, such as `operations` under Admin).
To place Jobs at **Admin > Operations > Jobs**, register the host Admin and Operations
apps first (Operations has Admin as its parent), then produce with:
```java
new JobsQBitProducer().withQBitConfig(new JobsQBitConfig()
.withParentAppName("operations")).produce(qInstance);
```
The parent must exist and differ from `appName`; Jobs attaches to its children.
With no parent configured, Jobs remains a top-level app. Placement does not grant permissions.
## Adopting in another app
1. Enable durable jobs in core (above) and create the tables. `docs/ddl/postgres.sql` is the Postgres
@@ -62,38 +77,30 @@ The tables are core's, prefixed `jobs_`. This qbit adds no tables.
## Build
```bash
./mvnw verify # tests, checkstyle (main and test sources), JaCoCo gate
./mvnw -o verify # tests, checkstyle (main and test sources), JaCoCo gate
```
The local demo server (`src/demo/java`, used for the screenshots) runs against a locally installed
QQQ build that has the durable job kernel, under a private version so it never replaces a shared
SNAPSHOT:
The local demo server (`src/demo/java`, used for the screenshots) uses released QQQ
4.1.0-RC.7 through the `demo` profile; no locally installed kernel build is needed.
```bash
# in a QQQ checkout of feature/durable-jobs-chunked:
mvn install -DskipTests -Drevision=4.1.0-JOBS-SNAPSHOT
# here:
./mvnw -Pdemo -Dqqq.snapshot.version=4.1.0-JOBS-SNAPSHOT test-compile exec:java -Dexec.classpathScope=test -Dexec.mainClass=com.kingsrook.qbits.jobs.demo.JobsDemoServer
./mvnw -o -Pdemo test-compile exec:java -Dexec.classpathScope=test -Dexec.mainClass=com.kingsrook.qbits.jobs.demo.JobsDemoServer
# then open http://localhost:8000/app/jobs
```
On Windows use `./mvnw.cmd` in place of `./mvnw`.
## Compatibility and display
## Display
The default build uses released core **4.1.0-RC.5**, which has no durable jobs kernel.
Production requires the kernel and the following core fixes (expected in RC.6):
- `QRun-IO/qqq#1178`: the jobs tables, constants and security metadata (alert types
`jobs.job-failed` and `jobs.job-stalled`).
- `QRun-IO/qqq#1173`: `StorageAction.deleteObject`. Until present, object deletion logs
once and is skipped. Job/child row retention still runs. Cleanup of inputs on retained
clean jobs preserves their references when deletion is unavailable, allowing retry.
Objects belonging to fully purged jobs may need the host's storage lifecycle policy.
The job progress fields and the Log list need QQQ 4.1.0-RC.8 or later; on older metadata
the optional progress fields may be absent and the Log list is left out.
The app produces typed data only, with no HTML. The widget label is each card's title,
so the data carries no second title.
- **Tables.** The running, attention and worker tables have two or three flexible
columns. Their cells are composite widget blocks:
- the job's label links to its record, with the process label under it;
- a progress bar shows the percent and rows done;
- a progress bar uses `percentDone` when present (including zero), otherwise rows/chunks;
- phase labels come from `progressJson.phases` with `currentPhase` as fallback;
- plain text shows the progress message and optional bytes in B/KiB/MiB/GiB;
- the status line is a label (or the waiting reason) with the rate and ETA.
Each row also keeps its plain values (`Job`, `Process`, `Status`, `Rows`, `Rate`,
`ETA`, `Path` and so on) for exports and other clients.
@@ -107,6 +114,16 @@ so the data carries no second title.
Retention is admin/system-only and restores the original session even on failure.
Its output contains standard process summary lines for jobs purged, chunk objects
removed during purge, and input objects removed (uploads plus retained-job inputs).
Jobs with effects outside SENT/FAILED, or with effects needing review, keep their rows
and inputs. When any are skipped, the summary includes "N jobs kept: effects still unsettled".
The Log list follows Live Progress, before Events: level, time, phase and message,
newest first, full width, with the standard child-list 25-row limit and View all link.
Live Progress also shows the ETA and a notice when `logDropped` is positive. The log is
read-only for users, including admins; retention deletes it through the system session in
keyset batches with 64-bit log cursors, before deleting chunks and jobs.
Needs attention shows each listed job's effect-review count; Live Progress adds an Effects
group (Sent, Pending, Failed, Needs review) when the job has effects.
## License
+47 -17
View File
@@ -14,17 +14,40 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
a CLI, iOS) can render it.
## Requirements traced (owner R1-R6)
- **R3, reportable.** Jobs, chunks, row errors and events are standard tables, with full record
screens, reports, Data Analysis and Quick Search. The home shows live progress, throughput, failures
and history. Every job links to its audit trail.
- **R4, notifications.** The alert types `jobs.job-failed` and `jobs.job-stalled`, and the `jobs.reaper`
heartbeat monitor, are declared here so that qbit-alerts routes them. Completion notifications come
from core.
- **R3, reportable.** Jobs, chunks, row errors, effects and events are standard tables, with full record
screens, reports and Data Analysis. Quick Search registers jobs only. The home shows live progress,
throughput, failures and history. Every job links to its audit trail.
- **R4, notifications.** The alert types `jobs.job-failed`, `jobs.job-stalled` and `jobs.effect-unresolved`
("Job effect needs review", WARNING), and the `jobs.reaper` heartbeat monitor, are declared here
so that qbit-alerts routes them. Completion notifications come from core.
- **R6, storage.** Retention deletes finished jobs and their chunk objects from object storage. The
qbit reads and writes no files.
## Progress and log adoption (core RC.8)
Implements sections 2 and 7 of core `JOB-PROGRESS-AND-JOBS-TRAY.md`. The default
BOM is 4.1.0-RC.8; local verification uses `-Dqqq.snapshot.version=4.1.0-SNAPSHOT`.
Optional names remain in `JobsCoreContract` without importing `core.jobs`.
- Home and Live Progress prefer nullable `percentDone`, including explicit zero,
then retain rows/chunks derivation. The snapshot supplies the current phase label;
missing or invalid JSON falls back to `currentPhase`. Both views include the
progress message, optional binary byte counts, and explicit `etaSeconds` ahead
of the existing rate-derived ETA. No HTML, fixed widths, or theme-specific colors.
- Log is a full-width standard child list immediately after Live Progress, before
Events, only if both table and join exist. It displays level, loggedAt, phase and
message, sorted by id ascending, with the standard 25-row preview/View all.
The panel reads through the current viewer and core joined owner lock.
- A positive `logDropped` produces an earlier-lines-not-kept notice in Live Progress.
- Retention deletes logs before chunks/jobs, using the existing 200-row keyset loop
with a long child cursor. Core `JobLogPermissions` explicitly permits system
sessions and denies user mutations even for admins. Tests load the actual checker.
- Hosts set `parentAppName` to their existing Operations app under Admin; the qbit
sets the parent and attaches Jobs as a child, while retaining configurable appName.
Missing/self parents fail validation before registration. Default remains top-level.
## The app
- `jobs` app, label **Jobs**. The nav lists `jobs_job` only. Chunks, row errors and events are child
- `jobs` app, label **Jobs**. The nav lists `jobs_job` only. Chunks, row errors, effects and events are child
lists on the job record.
- **Home**, built from typed widgets read in the viewer's session (record locks apply: a user sees
their own jobs, `jobs_admin` sees all). Live widgets set `defaultValues.refreshSeconds` (polling; the
@@ -41,6 +64,8 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
4. **Needs attention** (`TABLE`): FAILED, NEEDS_REVIEW and COMPLETED_WITH_ERRORS jobs from the
last 7 days. Failed and needs-review jobs come first, newest first within each group.
Each row shows the job, the error on its own line, the rows failed and the finish time.
The status cell includes "N effects need review" when applicable, from one viewer-scoped
effects query for the listed jobs. RUNNING jobs remain excluded; core moves them to NEEDS_REVIEW.
5. **Workers** (`TABLE`): pods from `jobs_worker`, with threads, draining, and last heartbeat
age (admins only).
6. **Job history** (`STACKED_BAR_CHART`): jobs per day over the last 30 days, by final
@@ -51,8 +76,13 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
- A **Live Progress** panel (`MULTI_STATISTICS`) has three groups: Rows (succeeded, failed,
total, and the percent done), Chunks (done, failed, total) and Speed (rows per minute, with
the ETA, the finish time or the status).
An optional fourth group, Effects, shows Sent, Pending (PENDING, SENDING and UNKNOWN
without needsReview), Failed and Needs review. Review effects appear only in Needs review.
The group is omitted when there are no effects or the effects table is absent.
It refreshes every 3s while the job is active.
- Child lists for **Chunks**, **Row errors** and **Events**.
- Child lists for **Log**, **Chunks**, **Row errors**, **Effects** and **Events**. Effects is
included only when the effects table and its inferred job join exist; it uses the same
joined owner lock as row errors.
- A **Changes made by this job** link to the audit table, filtered to messages containing
`job:<id>/` (core writes `job:<id>/chunk:<seq>` into every audit the job causes).
- The record actions are core's: Start, Pause, Resume, Cancel, Retry Failed, Retry Failed Rows,
@@ -61,7 +91,9 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
## Retention
`jobsPurgeFinished` is a hidden scheduled process, not in the nav. It runs daily by default.
- It deletes jobs that finished more than `retentionDays` ago (default 30), with their chunks, row
errors, events and chunk objects.
errors, effects, logs, events and chunk objects. Effects are removed before chunks and jobs.
- Jobs with any effect outside SENT/FAILED, or any effect needing review, keep their rows
and inputs. The summary reports "N jobs kept: effects still unsettled" when any are skipped.
- For COMPLETED jobs with no failed rows, it deletes the chunk input objects after
`inputRetentionDays` (default 7).
- Each run's summary is the standard process summary.
@@ -70,11 +102,9 @@ and phase 4. Owner decisions: all of that spec's recommendations are accepted (2
`JobsIntegrations.esbTableNames()`, `quickSearchTables()` and `dataAnalysisTables()`: a host passes
these to qqq-esb, qbit-quick-search and qbit-data-analysis.
## Dependencies and stand-ins
The qbit builds against the released QQQ RC through the default `qqq-snapshot` profile. Until the
kernel ships in an RC, the core names it needs (table, field and process names, and the jobs session
keys) are mirrored in `JobsCoreContract`, tagged
`STAND-IN(core=QRun-IO/qqq#1178, removeBy=RC.6)`. When RC.6 is released, those constants switch to
core's `DurableJobsTables`, and `JobsCoreContract` and the test-only table mirror are deleted.
The demo server (`-Pdemo`, under `src/demo/java`) runs against a locally installed 4.1.0-SNAPSHOT of
the kernel branch. It is how the screenshots are taken.
## Dependencies
Requires QQQ 4.1.0-RC.8 or later (job progress and `jobs_log`). The default `qqq-snapshot` profile and the opt-in
`demo` profile both use released RC.8. Core provides table metadata, joins, possible
values, session security, signal identifiers and storage deletion. The qbit declares
the alert types and retains only field names that core does not export.
The demo server under `src/demo/java` uses the same released kernel.
+61 -1
View File
@@ -1,4 +1,4 @@
-- Durable jobs schema, copied from core prime-durable-jobs.sql (QRun-IO/qqq#1178).
-- Durable jobs schema matching QQQ 4.1.0-RC.7 DurableJobsMetaDataProvider.
-- Snake-case columns; excludes test fixtures and destructive DROP statements.
CREATE TABLE jobs_job
@@ -21,6 +21,15 @@ CREATE TABLE jobs_job
semantics VARCHAR(20),
chunk_size INTEGER,
max_parallel_chunks INTEGER,
current_phase VARCHAR(250),
percent_done INTEGER,
progress_message VARCHAR(1000),
bytes_done BIGINT,
bytes_total BIGINT,
eta_seconds BIGINT,
progress_json TEXT,
log_seq BIGINT,
log_dropped INTEGER,
rows_total INTEGER,
rows_done INTEGER,
rows_ok INTEGER,
@@ -96,6 +105,43 @@ CREATE TABLE jobs_row_error
CREATE INDEX jobs_row_error_job_idx ON jobs_row_error (job_id);
-- Transactional outbox.
CREATE TABLE jobs_effect
(
id SERIAL PRIMARY KEY,
job_id INTEGER NOT NULL REFERENCES jobs_job (id) ON DELETE CASCADE,
effect_name VARCHAR(100),
status VARCHAR(30),
needs_review BOOLEAN DEFAULT FALSE,
row_key VARCHAR(250),
target_name VARCHAR(100),
effect_type VARCHAR(30),
chunk_id INTEGER,
chunk_seq INTEGER,
idempotency_key VARCHAR(500),
payload_json TEXT,
payload_ref VARCHAR(1000),
attempt INTEGER DEFAULT 0,
max_attempts INTEGER,
reconcile_attempts INTEGER DEFAULT 0,
next_attempt_at TIMESTAMPTZ,
lease_owner VARCHAR(250),
lease_token VARCHAR(100),
lease_expires_at TIMESTAMPTZ,
lease_version INTEGER DEFAULT 0,
provider_ref VARCHAR(500),
last_error TEXT,
sent_at TIMESTAMPTZ,
create_date TIMESTAMPTZ,
modify_date TIMESTAMPTZ
);
CREATE UNIQUE INDEX jobs_effect_idempotency_idx ON jobs_effect (idempotency_key);
CREATE INDEX jobs_effect_status_idx ON jobs_effect (status, next_attempt_at);
CREATE INDEX jobs_effect_job_idx ON jobs_effect (job_id);
CREATE TABLE jobs_event
(
id SERIAL PRIMARY KEY,
@@ -136,3 +182,17 @@ CREATE TABLE jobs_upload
job_id INTEGER,
create_date TIMESTAMPTZ
);
-- RC.8 job progress log; host migrations must also add the jobs_job columns above.
CREATE TABLE jobs_log
(
id BIGSERIAL PRIMARY KEY,
job_id INTEGER NOT NULL REFERENCES jobs_job(id) ON DELETE CASCADE,
chunk_id INTEGER REFERENCES jobs_chunk(id) ON DELETE SET NULL,
attempt INTEGER,
level VARCHAR(10) NOT NULL,
logged_at TIMESTAMPTZ NOT NULL,
phase VARCHAR(250),
message VARCHAR(1000)
);
CREATE INDEX jobs_log_cursor_idx ON jobs_log(job_id, id);
+8 -19
View File
@@ -18,6 +18,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<qqq.version>4.1.0-RC.26</qqq.version> <!-- feeds qbit-build-parent's qqq-bom-pom import -->
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
@@ -27,7 +28,7 @@
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
<version>2.2.1</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -171,7 +172,7 @@
<profiles>
<!-- Built against qqq 4.1. Until qbit-build-parent imports the released 4.1 BOM, this -->
<!-- profile is active by default and imports qqq-bom-pom 4.1.0-RC.5 ahead of the parent's -->
<!-- profile is active by default and imports qqq-bom-pom 4.1.0-RC.8 ahead of the parent's -->
<!-- 4.0.0 BOM (ADR-0007, as in the other qbits). -->
<profile>
<id>qqq-snapshot</id>
@@ -179,7 +180,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -192,26 +193,14 @@
</dependency>
</dependencies>
</dependencyManagement>
<repositories>
<repository>
<id>sonatype-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
</profile>
<!-- Local demo server and screenshots only (never in CI): builds src/demo/java against a -->
<!-- locally installed QQQ with the durable job kernel, e.g. -Dqqq.snapshot.version=4.1.0-JOBS-SNAPSHOT. -->
<!-- Local demo server and screenshots: builds src/demo/java against released QQQ RC.7. -->
<!-- The version property remains overridable, as in the default profile. -->
<profile>
<id>demo</id>
<properties>
<qqq.snapshot.version>4.1.0-SNAPSHOT</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.26</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -238,7 +227,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-frontend-next</artifactId>
<version>1.0.0-RC.19</version>
<version>1.0.0-RC.38</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -23,28 +23,51 @@ package com.kingsrook.qbits.jobs;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsTables;
import com.kingsrook.qqq.backend.core.jobs.effects.EffectStatus;
import com.kingsrook.qqq.backend.core.jobs.effects.JobEffectType;
import com.kingsrook.qqq.backend.core.jobs.model.ChunkStatus;
import com.kingsrook.qqq.backend.core.jobs.model.JobStatus;
import com.kingsrook.qqq.backend.core.jobs.runtime.JobSignals;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1178, removeBy=RC.6): mirrors core DurableJobsTables and friends; switch to core's constants when RC.6 is released.
** Core identifiers and field names used by the Jobs app.
*******************************************************************************/
public final class JobsCoreContract
{
public static final String JOBS_JOB = "jobs_job";
public static final String JOBS_CHUNK = "jobs_chunk";
public static final String JOBS_ROW_ERROR = "jobs_row_error";
public static final String JOBS_EVENT = "jobs_event";
public static final String JOBS_WORKER = "jobs_worker";
public static final String JOBS_UPLOAD = "jobs_upload";
public static final String JOBS_USER = "jobs_user";
public static final String JOBS_USER_ALL_ACCESS = "jobs_user_all_access";
public static final String JOBS_ADMIN = "jobs_admin";
public static final String ALERT_JOB_FAILED = "jobs.job-failed";
public static final String ALERT_JOB_STALLED = "jobs.job-stalled";
public static final String HEARTBEAT_REAPER = "jobs.reaper";
public static final String DURABLE_JOBS_METADATA = "durableJobs";
public static final String AUDIT = "audit";
// Optional progress and log contract (core 4.1.0-RC.8).
public static final String JOBS_LOG = DurableJobsTables.TABLE_LOG;
public static final String CURRENT_PHASE = "currentPhase";
public static final String PERCENT_DONE = "percentDone";
public static final String PROGRESS_MESSAGE = "progressMessage";
public static final String BYTES_DONE = "bytesDone";
public static final String BYTES_TOTAL = "bytesTotal";
public static final String ETA_SECONDS = "etaSeconds";
public static final String LOG_SEQ = "logSeq";
public static final String LOG_DROPPED = "logDropped";
public static final String PROGRESS_JSON = "progressJson";
public static final String LEVEL = "level";
public static final String LOGGED_AT = "loggedAt";
public static final String PHASE = "phase";
public static final String JOBS_JOB = DurableJobsTables.TABLE_JOB;
public static final String JOBS_CHUNK = DurableJobsTables.TABLE_CHUNK;
public static final String JOBS_ROW_ERROR = DurableJobsTables.TABLE_ROW_ERROR;
public static final String JOBS_EVENT = DurableJobsTables.TABLE_EVENT;
public static final String JOBS_WORKER = DurableJobsTables.TABLE_WORKER;
public static final String JOBS_UPLOAD = DurableJobsTables.TABLE_UPLOAD;
public static final String JOBS_USER = DurableJobsTables.SECURITY_KEY_USER;
public static final String JOBS_USER_ALL_ACCESS = DurableJobsTables.SECURITY_KEY_USER_ALL_ACCESS;
public static final String JOBS_ADMIN = DurableJobsTables.PERMISSION_ADMIN;
public static final String ALERT_JOB_FAILED = JobSignals.ALERT_JOB_FAILED;
public static final String ALERT_JOB_STALLED = JobSignals.ALERT_JOB_STALLED;
public static final String HEARTBEAT_REAPER = JobSignals.HEARTBEAT_REAPER;
public static final String DURABLE_JOBS_METADATA = DurableJobsMetaData.NAME;
public static final String AUDIT = AuditsMetaDataProvider.TABLE_NAME_AUDIT;
public static final String ID = "id";
public static final String LABEL = "label";
public static final String PROCESS_NAME = "processName";
@@ -118,21 +141,47 @@ public final class JobsCoreContract
public static final String STORAGE_TABLE_NAME = "storageTableName";
public static final String STORAGE_REFERENCE = "storageReference";
public static final String EXPIRES_AT = "expiresAt";
public static final String PLANNING = "PLANNING";
public static final String AWAITING_CONFIRMATION = "AWAITING_CONFIRMATION";
public static final String QUEUED = "QUEUED";
public static final String RUNNING = "RUNNING";
public static final String PAUSING = "PAUSING";
public static final String PAUSED = "PAUSED";
public static final String CANCELLING = "CANCELLING";
public static final String CANCELLED = "CANCELLED";
public static final String COMPLETED = "COMPLETED";
public static final String COMPLETED_WITH_ERRORS = "COMPLETED_WITH_ERRORS";
public static final String FAILED = "FAILED";
public static final String NEEDS_REVIEW = "NEEDS_REVIEW";
public static final String DONE = "DONE";
public static final String PLANNING = JobStatus.PLANNING.getPossibleValueId();
public static final String AWAITING_CONFIRMATION = JobStatus.AWAITING_CONFIRMATION.getPossibleValueId();
public static final String QUEUED = JobStatus.QUEUED.getPossibleValueId();
public static final String RUNNING = JobStatus.RUNNING.getPossibleValueId();
public static final String PAUSING = JobStatus.PAUSING.getPossibleValueId();
public static final String PAUSED = JobStatus.PAUSED.getPossibleValueId();
public static final String CANCELLING = JobStatus.CANCELLING.getPossibleValueId();
public static final String CANCELLED = JobStatus.CANCELLED.getPossibleValueId();
public static final String COMPLETED = JobStatus.COMPLETED.getPossibleValueId();
public static final String COMPLETED_WITH_ERRORS = JobStatus.COMPLETED_WITH_ERRORS.getPossibleValueId();
public static final String FAILED = JobStatus.FAILED.getPossibleValueId();
public static final String NEEDS_REVIEW = JobStatus.NEEDS_REVIEW.getPossibleValueId();
public static final String DONE = ChunkStatus.DONE.getPossibleValueId();
public static final List<String> DATA_TABLES = List.of(JOBS_JOB, JOBS_CHUNK, JOBS_ROW_ERROR, JOBS_EVENT, JOBS_WORKER);
public static final String JOBS_EFFECT = DurableJobsTables.TABLE_EFFECT;
public static final String JOBS_EFFECT_STATUS = EffectStatus.NAME;
public static final String JOBS_EFFECT_TYPE = JobEffectType.NAME;
public static final String ALERT_EFFECT_UNRESOLVED = JobSignals.ALERT_EFFECT_UNRESOLVED;
public static final String EFFECT_NAME = "effectName";
public static final String NEEDS_REVIEW_FIELD = "needsReview";
public static final String ROW_KEY = "rowKey";
public static final String TARGET_NAME = "targetName";
public static final String EFFECT_TYPE = "effectType";
public static final String CHUNK_SEQ = "chunkSeq";
public static final String IDEMPOTENCY_KEY = "idempotencyKey";
public static final String PAYLOAD_JSON = "payloadJson";
public static final String PAYLOAD_REF = "payloadRef";
public static final String RECONCILE_ATTEMPTS = "reconcileAttempts";
public static final String NEXT_ATTEMPT_AT = "nextAttemptAt";
public static final String PROVIDER_REF = "providerRef";
public static final String LAST_ERROR = "lastError";
public static final String SENT_AT = "sentAt";
public static final String PENDING = EffectStatus.PENDING.getPossibleValueId();
public static final String SENDING = EffectStatus.SENDING.getPossibleValueId();
public static final String SENT = EffectStatus.SENT.getPossibleValueId();
public static final String UNKNOWN = EffectStatus.UNKNOWN.getPossibleValueId();
public static final String LOCAL_TX = JobEffectType.LOCAL_TX.getPossibleValueId();
public static final String IDEMPOTENT_EXTERNAL = JobEffectType.IDEMPOTENT_EXTERNAL.getPossibleValueId();
public static final String NON_IDEMPOTENT_EXTERNAL = JobEffectType.NON_IDEMPOTENT_EXTERNAL.getPossibleValueId();
public static final List<String> DATA_TABLES = List.of(JOBS_JOB, JOBS_CHUNK, JOBS_ROW_ERROR, JOBS_EFFECT, JOBS_EVENT, JOBS_WORKER);
public static final List<String> ALL_TABLES = List.of(JOBS_JOB, JOBS_CHUNK, JOBS_ROW_ERROR, JOBS_EVENT, JOBS_WORKER, JOBS_UPLOAD);
public static final List<String> RUNNING_STATUSES = List.of(RUNNING, PAUSING, CANCELLING, PLANNING);
public static final List<String> QUEUED_STATUSES = List.of(QUEUED, AWAITING_CONFIRMATION);
@@ -151,7 +200,7 @@ public final class JobsCoreContract
*******************************************************************************/
public static String childJoin(String child)
{
return (QJoinMetaData.makeInferredJoinName(JOBS_JOB, child));
return (DurableJobsMetaDataProvider.childJoinName(child));
}
/*******************************************************************************
@@ -164,21 +213,13 @@ public final class JobsCoreContract
{
return ("Unknown");
}
return (switch(status)
try
{
case AWAITING_CONFIRMATION -> "Awaiting Confirmation";
case QUEUED -> "Queued";
case PLANNING -> "Planning";
case RUNNING -> "Running";
case PAUSING -> "Pausing";
case PAUSED -> "Paused";
case CANCELLING -> "Cancelling";
case CANCELLED -> "Cancelled";
case COMPLETED -> "Completed";
case COMPLETED_WITH_ERRORS -> "Completed With Errors";
case FAILED -> "Failed";
case NEEDS_REVIEW -> "Needs Review";
default -> status;
});
return (JobStatus.valueOf(status).getPossibleValueLabel());
}
catch(IllegalArgumentException e)
{
return (status);
}
}
}
@@ -54,7 +54,7 @@ public final class JobsIntegrations
}
/*******************************************************************************
** Reportable job data.
** Reportable job data, including the optional effects outbox.
*******************************************************************************/
public static List<String> dataAnalysisTables()
{
@@ -34,6 +34,7 @@ import org.quartz.CronExpression;
public class JobsQBitConfig implements QBitConfig
{
private String appName = "jobs";
private String parentAppName;
private int retentionDays = 30;
private int inputRetentionDays = 7;
private String purgeCronExpression = "0 30 3 * * ?";
@@ -41,6 +42,31 @@ public class JobsQBitConfig implements QBitConfig
private int recordRefreshSeconds = 3;
private String schedulerName = null;
/*******************************************************************************
** Get the optional host parent app.
*******************************************************************************/
public String getParentAppName()
{
return (parentAppName);
}
/*******************************************************************************
** Set the optional host parent app.
*******************************************************************************/
public void setParentAppName(String parentAppName)
{
this.parentAppName = parentAppName;
}
/*******************************************************************************
** Place Jobs under an existing host app, such as Operations under Admin.
*******************************************************************************/
public JobsQBitConfig withParentAppName(String parentAppName)
{
this.parentAppName = parentAppName;
return (this);
}
/*******************************************************************************
** Report invalid settings before adding any metadata.
*******************************************************************************/
@@ -48,6 +74,7 @@ public class JobsQBitConfig implements QBitConfig
public void validate(QInstance instance, List<String> errors)
{
assertCondition(appName != null && appName.matches("[A-Za-z][A-Za-z0-9_]*"), "appName must be a valid name", errors);
assertCondition(parentAppName == null || (!parentAppName.equals(appName) && instance.getApp(parentAppName) != null), "parentAppName must name an existing, different app", errors);
assertCondition(retentionDays > 0, "retentionDays must be positive", errors);
assertCondition(inputRetentionDays > 0 && inputRetentionDays <= retentionDays, "inputRetentionDays must be positive and no greater than retentionDays", errors);
assertCondition(homeRefreshSeconds > 0 && recordRefreshSeconds > 0, "refresh seconds must be positive", errors);
@@ -55,6 +82,15 @@ public class JobsQBitConfig implements QBitConfig
assertCondition(schedulerName == null || !schedulerName.isBlank(), "schedulerName must be null or nonblank", errors);
}
/*******************************************************************************
** The permission a user needs to see the Jobs app and its home widgets,
** "<appName>.hasAccess" (default "jobs.hasAccess"). Hosts must grant it.
*******************************************************************************/
public String getHasAccessPermissionName()
{
return (appName + ".hasAccess");
}
/*******************************************************************************
** Get appName.
*******************************************************************************/
@@ -45,6 +45,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
@@ -54,6 +55,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ALERT_EFFECT_UNRESOLVED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ALERT_JOB_FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ALERT_JOB_STALLED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ALL_TABLES;
@@ -62,9 +64,15 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.HEARTBEAT_REAPER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ADMIN;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_LOG;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEVEL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOGGED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PHASE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROW_NUMBER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SEQ;
@@ -160,12 +168,34 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
QWidgetMetaData workers = registerWidget(instance, "jobsHomeWorkers", "Workers", WidgetType.TABLE, qBitConfig.getHomeRefreshSeconds());
workers.withPermissionRules(adminRules());
registerWidget(instance, "jobsHomeHistory", "Job history", WidgetType.STACKED_BAR_CHART, qBitConfig.getHomeRefreshSeconds());
instance.addApp(new QAppMetaData().withName(qBitConfig.getAppName()).withLabel("Jobs").withIcon(new QIcon("pending_actions"))
.withChild(instance.getTable(JOBS_JOB)).withWidgets(new ArrayList<>(HOME_WIDGETS)));
for(String name : HOME_WIDGETS)
{
if(!"jobsHomeWorkers".equals(name))
{
instance.getWidget(name).setPermissionRules(appAccessRules());
}
}
QAppMetaData app = new QAppMetaData().withName(qBitConfig.getAppName()).withLabel("Jobs").withIcon(new QIcon("pending_actions"))
.withChild(instance.getTable(JOBS_JOB)).withWidgets(new ArrayList<>(HOME_WIDGETS))
.withPermissionRules(appAccessRules());
app.setParentAppName(qBitConfig.getParentAppName());
instance.addApp(app);
if(qBitConfig.getParentAppName() != null)
{
instance.getApp(qBitConfig.getParentAppName()).withChild(app);
}
registerWidget(instance, "jobsJobProgress", "Live Progress", WidgetType.MULTI_STATISTICS, qBitConfig.getRecordRefreshSeconds());
addRecordSection(instance, "jobsJobProgress", "Live Progress");
if(instance.getTable(JOBS_LOG) != null && instance.getJoin(JobsCoreContract.childJoin(JOBS_LOG)) != null)
{
child(instance, JOBS_LOG, "jobsJobLog", "Log", ID, false);
}
child(instance, JOBS_CHUNK, "jobsJobChunks", "Chunks", SEQ, true);
child(instance, JOBS_ROW_ERROR, "jobsJobRowErrors", "Row Errors", ROW_NUMBER, true);
if(instance.getTable(JOBS_EFFECT) != null && instance.getJoin(JobsCoreContract.childJoin(JOBS_EFFECT)) != null)
{
child(instance, JOBS_EFFECT, "jobsJobEffects", "Effects", ID, true);
}
child(instance, JOBS_EVENT, "jobsJobEvents", "Events", ID, false);
if(instance.getTable(AUDIT) != null)
{
@@ -197,6 +227,10 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
.withOrderBys(List.of(new QFilterOrderBy(order, ascending))).withMaxRows(25).getWidgetMetaData()
.withName(name).withLabel(label).withGridColumns(12).withCodeReference(new QCodeReference(JobsChildRecordListRenderer.class))
.withDefaultValue("refreshSeconds", qBitConfig.getRecordRefreshSeconds());
if(JOBS_LOG.equals(table))
{
widget.withDefaultValue(ChildRecordListRenderer.KEY_ONLY_INCLUDE_FIELD_NAMES, new ArrayList<>(List.of(LEVEL, LOGGED_AT, PHASE, MESSAGE)));
}
instance.addWidget(widget);
addRecordSection(instance, name, label);
}
@@ -210,6 +244,16 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
.withTier(Tier.T2).withWidgetName(widgetName).withGridColumns(12));
}
/*******************************************************************************
** The Jobs app and its home widgets require "<appName>.hasAccess"; users
** without it never see the app or its widgets (hidden, not locked).
*******************************************************************************/
private QPermissionRules appAccessRules()
{
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withPermissionBaseName(qBitConfig.getAppName()).withDenyBehavior(DenyBehavior.HIDDEN));
}
/*******************************************************************************
** Match the exact core permission, without a .hasAccess suffix.
*******************************************************************************/
@@ -245,6 +289,8 @@ public class JobsQBitProducer implements QBitMetaDataProducer<JobsQBitConfig>
.withSubjectTableName(JOBS_JOB).withSourceQBitName(QBIT_NAME).withHowToFix("Inspect the job error and row errors, correct the cause, then retry failed work."))
.withAlertType(new QAlertTypeMetaData().withName(ALERT_JOB_STALLED).withLabel("Job stalled").withDefaultSeverity(AlertSeverity.WARNING)
.withSubjectTableName(JOBS_JOB).withSourceQBitName(QBIT_NAME).withHowToFix("Check worker heartbeats, storage access and chunk leases; restore workers before retrying."))
.withAlertType(new QAlertTypeMetaData().withName(ALERT_EFFECT_UNRESOLVED).withLabel("Job effect needs review").withDefaultSeverity(AlertSeverity.WARNING)
.withSubjectTableName(JOBS_JOB).withSourceQBitName(QBIT_NAME).withHowToFix("Review the effect and confirm its outcome with the provider before retrying."))
.withAlertType(new QAlertTypeMetaData().withName(HEARTBEAT_REAPER).withLabel("Job reaper stopped").withDefaultSeverity(AlertSeverity.ERROR)
.withSourceQBitName(QBIT_NAME).withVisibilityPermission(JOBS_ADMIN).withDescription("No pod is reaping leases.")
.withHowToFix("Restore a durable jobs runtime so expired chunk leases are reclaimed.")
@@ -1,118 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.jobs;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1178, removeBy=RC.6): copy of core session security.
** Puts the durable-jobs security keys into a session: jobs_user = the user's id,
** plus jobs_user_all_access for sessions holding the jobs_admin permission.
**
** A host calls applyToSession from its authentication customizer
** (QAuthenticationModuleCustomizerInterface.finalCustomizeSession), the same as
** for the alerts keys. The kernel also ensures the keys on the sessions it
** builds for workers and on launch/control calls, so a host that forgets still
** gets correct (owner-only) behaviour there; a session without the keys sees no
** jobs through the standard table screens (the lock fails closed).
*******************************************************************************/
public final class JobsSecurity
{
/*******************************************************************************
** not instantiable
*******************************************************************************/
private JobsSecurity()
{
}
/*******************************************************************************
** Replace any jobs keys on the session with ones derived from its user and
** permissions. No-op for null and system sessions.
*******************************************************************************/
public static void applyToSession(QSession session)
{
if(session == null || session instanceof QSystemUserSession)
{
return;
}
Map<String, List<Serializable>> keys = session.getSecurityKeyValues();
if(keys != null)
{
keys.remove(JobsCoreContract.JOBS_USER);
keys.remove(JobsCoreContract.JOBS_USER_ALL_ACCESS);
}
String userId = userId(session);
if(StringUtils.hasContent(userId))
{
session.withSecurityKeyValue(JobsCoreContract.JOBS_USER, userId);
}
if(session.hasPermission(JobsCoreContract.JOBS_ADMIN))
{
session.withSecurityKeyValue(JobsCoreContract.JOBS_USER_ALL_ACCESS, true);
}
}
/*******************************************************************************
** Apply the keys only when the session has none yet.
*******************************************************************************/
public static void ensureSessionKeys(QSession session)
{
if(session == null || session instanceof QSystemUserSession)
{
return;
}
if(session.getSecurityKeyValues(JobsCoreContract.JOBS_USER).isEmpty())
{
applyToSession(session);
}
}
/*******************************************************************************
** The id the jobs tables record as a job's owner.
*******************************************************************************/
public static String userId(QSession session)
{
if(session == null || session.getUser() == null)
{
return (null);
}
return (session.getUser().getIdReference());
}
}
@@ -51,17 +51,22 @@ import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FINISHED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.INPUT_STORAGE_REF;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ADMIN;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_LOG;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_UPLOAD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW_FIELD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STATUS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STORAGE_REFERENCE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STORAGE_TABLE_NAME;
@@ -86,7 +91,7 @@ public class JobsPurgeFinishedStep implements BackendStep
}
/*******************************************************************************
** Storage seam for testing RC.6 deletion semantics against RC.5.
** Storage seam for testing deletion failures and retries.
*******************************************************************************/
public JobsPurgeFinishedStep(JobsStorage storage)
{
@@ -105,7 +110,7 @@ public class JobsPurgeFinishedStep implements BackendStep
}
JobsQBitConfig config = (JobsQBitConfig) QContext.getQInstance().getQBits().get(JobsQBitProducer.QBIT_NAME).getConfig();
QSession caller = QContext.getQSession();
int[] counts = new int[3];
int[] counts = new int[4];
try
{
QContext.setQSession(new QSystemUserSession());
@@ -121,6 +126,10 @@ public class JobsPurgeFinishedStep implements BackendStep
summary.add(new ProcessSummaryLine(Status.INFO, counts[0], "jobs purged"));
summary.add(new ProcessSummaryLine(Status.INFO, counts[1], "chunk objects deleted"));
summary.add(new ProcessSummaryLine(Status.INFO, counts[2], "input objects deleted"));
if(counts[3] > 0)
{
summary.add(new ProcessSummaryLine(Status.INFO, counts[3], "jobs kept: effects still unsettled"));
}
output.addValue(StreamedETLWithFrontendProcess.FIELD_PROCESS_SUMMARY, summary);
}
@@ -142,6 +151,19 @@ public class JobsPurgeFinishedStep implements BackendStep
for(QRecord job : jobs)
{
lastId = job.getValueInteger(ID);
if(hasUnsettledEffects(lastId))
{
counts[3]++;
continue;
}
if(QContext.getQInstance().getTable(JOBS_EFFECT) != null)
{
removeChildren(JOBS_EFFECT, lastId, counts);
}
if(QContext.getQInstance().getTable(JOBS_LOG) != null)
{
removeChildren(JOBS_LOG, lastId, counts);
}
removeChildren(JOBS_ROW_ERROR, lastId, counts);
removeChildren(JOBS_EVENT, lastId, counts);
removeChildren(JOBS_CHUNK, lastId, counts);
@@ -152,12 +174,29 @@ public class JobsPurgeFinishedStep implements BackendStep
}
}
/*******************************************************************************
** Preserve unresolved outbox work, including unknown or missing statuses.
*******************************************************************************/
private boolean hasUnsettledEffects(int jobId) throws QException
{
if(QContext.getQInstance().getTable(JOBS_EFFECT) == null)
{
return (false);
}
QQueryFilter unsettled = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withCriteria(STATUS, QCriteriaOperator.NOT_IN, List.of(SENT, FAILED))
.withCriteria(STATUS, QCriteriaOperator.IS_BLANK)
.withCriteria(NEEDS_REVIEW_FIELD, QCriteriaOperator.EQUALS, true);
QQueryFilter filter = new QQueryFilter().withCriteria(JOB_ID, QCriteriaOperator.EQUALS, jobId).withSubFilter(unsettled).withLimit(1);
return (!new QueryAction().execute(new QueryInput().withTableName(JOBS_EFFECT).withFilter(filter)).getRecords().isEmpty());
}
/*******************************************************************************
** Page all children, including uploads, before removing a job.
*******************************************************************************/
private void removeChildren(String table, int jobId, int[] counts) throws QException
{
int lastId = 0;
long lastId = 0;
while(true)
{
List<QRecord> records = batch(table, new QQueryFilter().withCriteria(JOB_ID, QCriteriaOperator.EQUALS, jobId), lastId);
@@ -167,7 +206,7 @@ public class JobsPurgeFinishedStep implements BackendStep
}
for(QRecord record : records)
{
lastId = record.getValueInteger(ID);
lastId = record.getValueLong(ID);
if(JOBS_CHUNK.equals(table) && storage.delete(storage.chunkStorageTable(), record.getValueString(INPUT_STORAGE_REF)))
{
counts[1]++;
@@ -200,7 +239,10 @@ public class JobsPurgeFinishedStep implements BackendStep
for(QRecord job : jobs)
{
lastId = job.getValueInteger(ID);
cleanJobInputs(lastId, counts);
if(!hasUnsettledEffects(lastId))
{
cleanJobInputs(lastId, counts);
}
}
}
}
@@ -239,7 +281,7 @@ public class JobsPurgeFinishedStep implements BackendStep
/*******************************************************************************
** Keyset pagination remains correct when rows are deleted or updated.
*******************************************************************************/
private List<QRecord> batch(String table, QQueryFilter filter, int lastId) throws QException
private List<QRecord> batch(String table, QQueryFilter filter, long lastId) throws QException
{
return (new QueryAction().execute(new QueryInput().withTableName(table).withFilter(filter.withCriteria(ID, QCriteriaOperator.GREATER_THAN, lastId)
.withOrderBy(new QFilterOrderBy(ID)).withLimit(BATCH_SIZE))).getRecords());
@@ -22,49 +22,29 @@
package com.kingsrook.qbits.jobs.processes;
import java.util.concurrent.atomic.AtomicBoolean;
import com.kingsrook.qqq.backend.core.actions.tables.StorageAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
import com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DURABLE_JOBS_METADATA;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1173, removeBy=RC.6): reflectively call StorageAction
** deleteObject until the released core contains that method. Never touch disk.
** STAND-IN(core=QRun-IO/qqq#1178, removeBy=RC.6): read durable jobs supplemental
** metadata reflectively until its type is available in the released core.
** Object cleanup through core storage actions and durable jobs configuration.
*******************************************************************************/
public class JobsStorage
{
private static final QLogger LOG = QLogger.getLogger(JobsStorage.class);
private static final AtomicBoolean WARNED = new AtomicBoolean();
/*******************************************************************************
** Look up the host's durable jobs storage table without linking core.jobs.
** Look up the host's durable jobs storage table.
*******************************************************************************/
public String chunkStorageTable() throws QException
{
Object metadata = QContext.getQInstance().getSupplementalMetaData(DURABLE_JOBS_METADATA);
if(metadata == null)
{
return (null);
}
try
{
return ((String) metadata.getClass().getMethod("getStorageTableName").invoke(metadata));
}
catch(ReflectiveOperationException e)
{
throw (new QException("Cannot read durable jobs storage metadata", e));
}
DurableJobsMetaData metadata = DurableJobsMetaData.of(QContext.getQInstance());
return (metadata == null ? null : metadata.getStorageTableName());
}
/*******************************************************************************
** Return true only after a successful deletion. Missing APIs leave references
** intact during input cleanup. Invocation failures abort retention for retry.
** Return true only after successful deletion; failures abort retention for retry.
*******************************************************************************/
public boolean delete(String table, String reference) throws QException
{
@@ -72,22 +52,7 @@ public class JobsStorage
{
return (false);
}
try
{
StorageAction.class.getMethod("deleteObject", StorageInput.class).invoke(new StorageAction(), new StorageInput(table).withReference(reference));
return (true);
}
catch(NoSuchMethodException e)
{
if(WARNED.compareAndSet(false, true))
{
LOG.warn("StorageAction.deleteObject is unavailable in this core release; skipping jobs object deletion until RC.6");
}
return (false);
}
catch(ReflectiveOperationException e)
{
throw (new QException("Could not delete durable jobs storage object", e));
}
new StorageAction().deleteObject(new StorageInput(table).withReference(reference));
return (true);
}
}
@@ -22,10 +22,10 @@
package com.kingsrook.qbits.jobs.widgets;
import com.kingsrook.qbits.jobs.JobsSecurity;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsSecurity;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
@@ -41,7 +41,7 @@ public class JobsChildRecordListRenderer extends ChildRecordListRenderer
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
JobsSecurity.ensureSessionKeys(QContext.getQSession());
DurableJobsSecurity.ensureSessionKeys(QContext.getQSession());
return (super.render(input));
}
}
@@ -52,6 +52,24 @@ public final class JobsFormat
/*******************************************************************************
** Binary byte units, with one decimal place above bytes.
*******************************************************************************/
public static String bytes(long value)
{
double amount = Math.max(0, value);
String[] units = {"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"};
int unit = 0;
while(amount >= 1024 && unit < units.length - 1)
{
amount /= 1024;
unit++;
}
return (unit == 0 ? count((long) amount) + " B" : String.format(Locale.US, "%.1f %s", amount, units[unit]));
}
/*******************************************************************************
** A count with thousands separators.
*******************************************************************************/
@@ -22,6 +22,7 @@
package com.kingsrook.qbits.jobs.widgets;
import java.io.IOException;
import java.io.Serializable;
import java.math.BigDecimal;
import java.net.URLEncoder;
@@ -43,12 +44,13 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import com.kingsrook.qbits.jobs.JobsSecurity;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsSecurity;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
@@ -73,28 +75,40 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ATTENTION_STATUSES;
import static com.kingsrook.qbits.jobs.JobsCoreContract.AUDIT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED_WITH_ERRORS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CURRENT_PHASE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DRAINING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ERROR_MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ETA_SECONDS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FINISHED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.HEARTBEAT_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ADMIN;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_WORKER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LABEL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LAST_PROGRESS_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOG_DROPPED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW_FIELD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PENDING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PERCENT_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.POD_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PRIORITY;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROCESS_NAME;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.QUEUED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.QUEUED_STATUSES;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_DONE;
@@ -103,11 +117,14 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_IN;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_OK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RUNNING_STATUSES;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SENDING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STARTED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STATUS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STATUS_REASON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.TERMINAL_STATUSES;
import static com.kingsrook.qbits.jobs.JobsCoreContract.THREADS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.UNKNOWN;
import static com.kingsrook.qbits.jobs.JobsCoreContract.statusLabel;
@@ -136,7 +153,7 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
JobsSecurity.ensureSessionKeys(QContext.getQSession());
DurableJobsSecurity.ensureSessionKeys(QContext.getQSession());
Instant now = Instant.now();
QWidgetData data = switch(input.getWidgetMetaData().getName())
{
@@ -160,7 +177,7 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
*******************************************************************************/
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
{
JobsSecurity.ensureSessionKeys(QContext.getQSession());
DurableJobsSecurity.ensureSessionKeys(QContext.getQSession());
return (new QueryAction().execute(new QueryInput().withTableName(table).withFilter(filter)
.withShouldFetchHeavyFields(true)).getRecords());
}
@@ -298,6 +315,13 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
progress.withBlock(text("Unknown total", null, null));
}
progress.withBlock(small(rowText + " rows"));
for(String detail : progressDetails(job))
{
progress.withBlock(small(detail));
}
row.put("Phase", phaseLabel(job));
row.put("Message", job.getValueString(PROGRESS_MESSAGE));
row.put("Bytes", bytes(job));
row.put(JOB_CELL, jobCell(job, null));
row.put(PROGRESS_CELL, progress);
@@ -321,8 +345,19 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
jobs.sort(Comparator.comparingInt((QRecord job) -> COMPLETED_WITH_ERRORS.equals(job.getValueString(STATUS)) ? 1 : 0)
.thenComparing((QRecord job) -> job.getValueInstant(FINISHED_AT), Comparator.nullsLast(Comparator.reverseOrder())));
List<QRecord> listedJobs = jobs.subList(0, Math.min(25, jobs.size()));
Map<Integer, Integer> reviewCounts = new HashMap<>();
if(!listedJobs.isEmpty() && QContext.getQInstance().getTable(JOBS_EFFECT) != null)
{
QQueryFilter filter = new QQueryFilter().withCriteria(JOB_ID, QCriteriaOperator.IN, listedJobs.stream().map(job -> job.getValueInteger(ID)).toList())
.withCriteria(NEEDS_REVIEW_FIELD, QCriteriaOperator.EQUALS, true);
for(QRecord effect : query(JOBS_EFFECT, filter))
{
reviewCounts.merge(effect.getValueInteger(JOB_ID), 1, Integer::sum);
}
}
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord job : jobs.subList(0, Math.min(25, jobs.size())))
for(QRecord job : listedJobs)
{
String error = JobsFormat.clip(job.getValueString(ERROR_MESSAGE), 120);
String failed = count(job, ROWS_FAILED) + (number(job, ROWS_FAILED) == 1 ? " row failed" : " rows failed");
@@ -336,6 +371,11 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
{
statusCell.withBlock(small(failed));
}
int effectsNeedingReview = reviewCounts.getOrDefault(job.getValueInteger(ID), 0);
if(effectsNeedingReview > 0)
{
statusCell.withBlock(small(effectsNeedingReview + " effects need review"));
}
row.put(STATUS_CELL, statusCell.withBlock(small(finished)));
rows.add(row);
}
@@ -456,9 +496,9 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
/*******************************************************************************
** Record progress: rows, chunks and speed, without disclosing an inaccessible job.
** Record progress: rows, chunks, speed and optional effects, scoped to the viewer.
*******************************************************************************/
private MultiStatisticsData progress(QRecord job, Instant now)
private MultiStatisticsData progress(QRecord job, Instant now) throws QException
{
if(job == null)
{
@@ -475,7 +515,7 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
rowStats.add(statistic("Total", number(job, ROWS_TOTAL), null));
}
MultiStatisticsData.StatisticsGroupData rows = new MultiStatisticsData.StatisticsGroupData().withHeader("Rows")
.withSubheader(job.getValue(ROWS_TOTAL) == null ? "Total not known yet" : percent + " done").withStatisticList(rowStats);
.withSubheader(percentValue(job) == null ? "Total not known yet" : percent + " done").withStatisticList(rowStats);
List<MultiStatisticsData.StatisticsGroupData.Statistic> chunkStats = new ArrayList<>();
chunkStats.add(statistic("Done", number(job, CHUNKS_DONE), null));
@@ -491,7 +531,7 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
{
speedNote = statusLabel(status) + " " + JobsFormat.time(job.getValueInstant(FINISHED_AT), now);
}
else if(RUNNING_STATUSES.contains(status))
else if(job.getValue(ETA_SECONDS) != null || RUNNING_STATUSES.contains(status))
{
speedNote = "ETA " + eta(job, now);
}
@@ -501,7 +541,47 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
}
MultiStatisticsData.StatisticsGroupData speed = new MultiStatisticsData.StatisticsGroupData().withHeader("Speed").withSubheader(speedNote)
.withStatisticList(List.of(statistic("Rows per minute", Math.round(rate(job, now)), null)));
return (new MultiStatisticsData(null, List.of(rows, chunks, speed)));
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>(List.of(rows, chunks, speed));
List<String> details = progressDetails(job);
if(!details.isEmpty())
{
groups.addFirst(new MultiStatisticsData.StatisticsGroupData().withHeader("Progress")
.withSubheader(String.join(" | ", details)).withStatisticList(List.of()));
}
if(number(job, LOG_DROPPED) > 0)
{
groups.add(new MultiStatisticsData.StatisticsGroupData().withHeader("Log")
.withSubheader(JobsFormat.count(number(job, LOG_DROPPED)) + " earlier lines not kept").withStatisticList(List.of()));
}
if(QContext.getQInstance().getTable(JOBS_EFFECT) != null)
{
List<QRecord> effects = query(JOBS_EFFECT, new QQueryFilter().withCriteria(JOB_ID, QCriteriaOperator.EQUALS, job.getValue(ID)));
if(!effects.isEmpty())
{
long sent = 0;
long pending = 0;
long failed = 0;
long review = 0;
for(QRecord effect : effects)
{
if(Boolean.TRUE.equals(effect.getValueBoolean(NEEDS_REVIEW_FIELD)))
{
review++;
}
else
{
String effectStatus = effect.getValueString(STATUS);
sent += SENT.equals(effectStatus) ? 1 : 0;
failed += FAILED.equals(effectStatus) ? 1 : 0;
pending += PENDING.equals(effectStatus) || SENDING.equals(effectStatus) || UNKNOWN.equals(effectStatus) ? 1 : 0;
}
}
groups.add(new MultiStatisticsData.StatisticsGroupData().withHeader("Effects").withStatisticList(List.of(
statistic("Sent", sent, null), statistic("Pending", pending, null),
statistic("Failed", failed, null), statistic("Needs review", review, null))));
}
}
return (new MultiStatisticsData(null, groups));
}
@@ -708,10 +788,69 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
/*******************************************************************************
** Percent done from rows, else from chunks; null when neither total is known.
** Resolve the phase label from the optional snapshot; old or malformed data
** still displays the reported phase name.
*******************************************************************************/
private static String phaseLabel(QRecord job)
{
String current = job.getValueString(CURRENT_PHASE);
String json = job.getValueString(PROGRESS_JSON);
if(current != null && json != null)
{
try
{
JsonNode snapshot = JsonUtils.toObject(json, JsonNode.class);
for(JsonNode phase : snapshot == null ? List.<JsonNode>of() : snapshot.path("phases"))
{
if(current.equals(phase.path("name").asText()) && !phase.path("label").asText().isBlank())
{
return (phase.path("label").asText());
}
}
}
catch(IOException ignored)
{
// The denormalized progress remains usable without a valid snapshot.
}
}
return (current);
}
/*******************************************************************************
** Optional byte counts, retaining unknown totals rather than inventing zero.
*******************************************************************************/
private static String bytes(QRecord job)
{
String done = job.getValue(BYTES_DONE) == null ? null : JobsFormat.bytes(number(job, BYTES_DONE));
String total = job.getValue(BYTES_TOTAL) == null ? null : JobsFormat.bytes(number(job, BYTES_TOTAL));
return (total == null ? done : done == null ? total + " total" : done + " / " + total);
}
/*******************************************************************************
** Plain typed text shared by the home and record progress views.
*******************************************************************************/
private static List<String> progressDetails(QRecord job)
{
List<String> details = new ArrayList<>();
for(String detail : new String[] {phaseLabel(job), job.getValueString(PROGRESS_MESSAGE), bytes(job)})
{
if(detail != null && !detail.isBlank())
{
details.add(detail);
}
}
return (details);
}
/*******************************************************************************
** Explicit percent, else rows then chunks; null when no total is known.
*******************************************************************************/
private static BigDecimal percentValue(QRecord job)
{
if(job.getValue(PERCENT_DONE) != null)
{
return (BigDecimal.valueOf(Math.max(0, Math.min(100, number(job, PERCENT_DONE)))));
}
long total = number(job, ROWS_TOTAL);
long done = number(job, ROWS_DONE);
if(total <= 0)
@@ -753,6 +892,10 @@ public class JobsWidgetRenderer extends AbstractWidgetRenderer
*******************************************************************************/
private static String eta(QRecord job, Instant now)
{
if(job.getValue(ETA_SECONDS) != null)
{
return ("in " + JobsFormat.span(number(job, ETA_SECONDS)));
}
double rate = rate(job, now);
if(!RUNNING_STATUSES.contains(job.getValueString(STATUS)) || rate <= 0 || job.getValue(ROWS_TOTAL) == null)
{
@@ -27,24 +27,25 @@ import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.QWidgetData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
@@ -64,14 +65,19 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED_WITH_ERRORS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DETAIL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DRAINING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.EFFECT_NAME;
import static com.kingsrook.qbits.jobs.JobsCoreContract.EFFECT_TYPE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ERROR_MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FINISHED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.HEARTBEAT_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.IDEMPOTENCY_KEY;
import static com.kingsrook.qbits.jobs.JobsCoreContract.IDEMPOTENT_EXTERNAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.INPUT_STORAGE_REF;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ADMIN;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
@@ -82,6 +88,7 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.LABEL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LAST_PROGRESS_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW_FIELD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PAUSED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PAUSING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PLANNING;
@@ -130,9 +137,9 @@ public class BaseJobsTest
instance = new QInstance();
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK));
JobsTablesStandIn.defineAll(instance);
instance.addTable(new QTableMetaData().withName(AUDIT).withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.INTEGER)).withField(new QFieldMetaData(MESSAGE, QFieldType.STRING)));
new DurableJobsMetaDataProvider().defineAll(instance, "memory", null);
instance.withSupplementalMetaData(new DurableJobsMetaData());
new AuditsMetaDataProvider().defineAll(instance, "memory", null);
config = new JobsQBitConfig();
new JobsQBitProducer().withQBitConfig(config).produce(instance);
new QInstanceEnricher(instance).enrich();
@@ -202,6 +209,16 @@ public class BaseJobsTest
insert(AUDIT, new QRecord().withValue(MESSAGE, "job:" + id + "/chunk:1 changed a row"));
}
/*******************************************************************************
** An outbox entry with a unique idempotency key, independent of other fixtures.
*******************************************************************************/
protected QRecord effect(QRecord job, String status, boolean needsReview) throws QException
{
return (insert(JOBS_EFFECT, new QRecord().withValue(JOB_ID, job.getValue(ID)).withValue(EFFECT_NAME, "Send notification")
.withValue(STATUS, status).withValue(NEEDS_REVIEW_FIELD, needsReview).withValue(EFFECT_TYPE, IDEMPOTENT_EXTERNAL)
.withValue(IDEMPOTENCY_KEY, UUID.randomUUID().toString())));
}
/*******************************************************************************
** Every status plus another owner's running job and one worker.
*******************************************************************************/
@@ -0,0 +1,109 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.jobs;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataOutput;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The Jobs app and its home widgets are hidden from users without
** "jobs.hasAccess"; Workers additionally needs jobs_admin.
*******************************************************************************/
class JobsAppPermissionTest extends BaseJobsTest
{
private static final List<String> NON_ADMIN_WIDGETS = JobsQBitProducer.HOME_WIDGETS.stream().filter(w -> !"jobsHomeWorkers".equals(w)).toList();
/*******************************************************************************
** The permission name is public and follows the configured app name.
*******************************************************************************/
@Test
void permissionNameAndRules()
{
assertThat(config.getHasAccessPermissionName()).isEqualTo("jobs.hasAccess");
assertThat(new JobsQBitConfig().withAppName("bg").getHasAccessPermissionName()).isEqualTo("bg.hasAccess");
assertThat(instance.getApp("jobs").getPermissionRules().getLevel()).isEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
assertThat(instance.getApp("jobs").getPermissionRules().getPermissionBaseName()).isEqualTo("jobs");
assertThat(instance.getApp("jobs").getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
assertThat(instance.getWidget("jobsJobProgress").getPermissionRules().getLevel()).isNotEqualTo(PermissionLevel.HAS_ACCESS_PERMISSION);
for(String name : NON_ADMIN_WIDGETS)
{
assertThat(instance.getWidget(name).getPermissionRules().getPermissionBaseName()).isEqualTo("jobs");
assertThat(instance.getWidget(name).getPermissionRules().getDenyBehavior()).isEqualTo(DenyBehavior.HIDDEN);
}
}
/*******************************************************************************
** No permissions: no app, no home widgets, every render denied.
*******************************************************************************/
@Test
void noPermissionsSeesNothing() throws Exception
{
instance.setEnforcePermissionsInCoreActions(true);
login("nobody", false);
MetaDataOutput output = new MetaDataAction().execute(new MetaDataInput());
assertThat(output.getApps()).doesNotContainKey("jobs");
assertThat(output.getWidgets()).doesNotContainKeys(JobsQBitProducer.HOME_WIDGETS.toArray(String[]::new));
for(String name : JobsQBitProducer.HOME_WIDGETS)
{
assertThatThrownBy(() -> render(name, null)).isInstanceOf(QPermissionDeniedException.class);
}
}
/*******************************************************************************
** jobs.hasAccess: app and the five widgets, Workers only with jobs_admin.
*******************************************************************************/
@Test
void hasAccessSeesAppAndWidgets() throws Exception
{
instance.setEnforcePermissionsInCoreActions(true);
login("user", false).withPermission(config.getHasAccessPermissionName());
MetaDataOutput output = new MetaDataAction().execute(new MetaDataInput());
assertThat(output.getApps()).containsKey("jobs");
assertThat(output.getWidgets()).containsKeys(NON_ADMIN_WIDGETS.toArray(String[]::new));
assertThat(output.getWidgets()).doesNotContainKey("jobsHomeWorkers");
for(String name : NON_ADMIN_WIDGETS)
{
assertThat(render(name, null)).isNotNull();
}
assertThatThrownBy(() -> render("jobsHomeWorkers", null)).isInstanceOf(QPermissionDeniedException.class);
login("admin", true).withPermission(config.getHasAccessPermissionName());
assertThat(new MetaDataAction().execute(new MetaDataInput()).getWidgets()).containsKey("jobsHomeWorkers");
assertThat(render("jobsHomeWorkers", null)).isNotNull();
}
}
@@ -0,0 +1,290 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.jobs;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.jobs.processes.JobsPurgeFinishedStep;
import com.kingsrook.qbits.jobs.widgets.JobsFormat;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.instances.QInstanceEnricher;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.BYTES_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.COMPLETED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CURRENT_PHASE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ETA_SECONDS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_LOG;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_USER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEVEL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOGGED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOG_DROPPED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LOG_SEQ;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PERCENT_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PHASE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROGRESS_MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RUNNING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.USER_ID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Optional RC.8 progress and logs, including the actual core log permission checker.
*******************************************************************************/
class JobsProgressTest extends BaseJobsTest
{
/*******************************************************************************
** Add optional fields and a log using the real kernel permission checker by
** name, preserving the default suite's coverage of older kernel metadata.
*******************************************************************************/
private void enableProgress() throws Exception
{
for(String field : List.of(CURRENT_PHASE, PROGRESS_MESSAGE, PROGRESS_JSON))
{
instance.getTable(JOBS_JOB).withField(new QFieldMetaData(field, QFieldType.TEXT));
}
for(String field : List.of(PERCENT_DONE, LOG_DROPPED))
{
instance.getTable(JOBS_JOB).withField(new QFieldMetaData(field, QFieldType.INTEGER));
}
for(String field : List.of(BYTES_DONE, BYTES_TOTAL, ETA_SECONDS, LOG_SEQ))
{
instance.getTable(JOBS_JOB).withField(new QFieldMetaData(field, QFieldType.LONG));
}
instance.setEnforcePermissionsInCoreActions(true);
//////////////////////////////////////////////////////////////////////
// core 4.1.0-RC.8 defines jobs_log; older metadata gets this mirror //
//////////////////////////////////////////////////////////////////////
if(instance.getTable(JOBS_LOG) != null)
{
return;
}
instance.addTable(new QTableMetaData().withName(JOBS_LOG).withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.LONG))
.withField(new QFieldMetaData(JOB_ID, QFieldType.INTEGER))
.withField(new QFieldMetaData(LEVEL, QFieldType.STRING))
.withField(new QFieldMetaData(LOGGED_AT, QFieldType.DATE_TIME))
.withField(new QFieldMetaData(PHASE, QFieldType.STRING))
.withField(new QFieldMetaData(MESSAGE, QFieldType.STRING))
.withPermissionRules(new QPermissionRules().withCustomPermissionChecker(new QCodeReference(
Class.forName("com.kingsrook.qqq.backend.core.jobs.progress.JobLogPermissions"))))
.withRecordSecurityLock(new RecordSecurityLock().withSecurityKeyType(JOBS_USER).withFieldName(JOBS_JOB + "." + USER_ID)
.withJoinNameChain(List.of(JobsCoreContract.childJoin(JOBS_LOG)))));
instance.addJoin(new QJoinMetaData().withLeftTable(JOBS_JOB).withRightTable(JOBS_LOG).withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn(ID, JOB_ID)).withInferredName());
clearPresentation();
new JobsQBitProducer().withQBitConfig(config).produce(instance);
new QInstanceEnricher(instance).enrich();
}
/*******************************************************************************
** Rich fields override counters, resolve labels, and remain typed and flexible.
*******************************************************************************/
@Test
void richProgressOnHomeAndRecord() throws Exception
{
enableProgress();
QRecord job = job(USER_A, RUNNING, null);
update(job, new QRecord().withValue(CURRENT_PHASE, "upload").withValue(PERCENT_DONE, 73)
.withValue(PROGRESS_MESSAGE, "Uploading manifest").withValue(BYTES_DONE, 1536L).withValue(BYTES_TOTAL, 1048576L)
.withValue(ETA_SECONDS, 90L).withValue(LOG_DROPPED, 12)
.withValue(PROGRESS_JSON, "{\"phases\":[{\"name\":\"upload\",\"label\":\"Upload backup\"}]}"));
login(USER_A, false).withPermission(config.getHasAccessPermissionName());
TableData home = (TableData) render("jobsHomeActive", null);
assertThat(home.getRows()).singleElement().satisfies(row -> assertThat(row)
.containsEntry("Progress", "73%").containsEntry("Phase", "Upload backup")
.containsEntry("Message", "Uploading manifest").containsEntry("Bytes", "1.5 KiB / 1.0 MiB").containsEntry("ETA", "in 1m"));
assertThat(home.getColumns()).hasSize(3).allSatisfy(column -> assertThat(column.getWidth()).isNull());
MultiStatisticsData record = (MultiStatisticsData) render("jobsJobProgress", job.getValueString(ID));
assertThat(record.getStatisticsGroupData()).extracting(MultiStatisticsData.StatisticsGroupData::getSubheader)
.contains("Upload backup | Uploading manifest | 1.5 KiB / 1.0 MiB", "73% done", "ETA in 1m", "12 earlier lines not kept");
login(USER_B, false).withPermission(config.getHasAccessPermissionName());
assertThat(((TableData) render("jobsHomeActive", null)).getRows()).isEmpty();
assertThat(((MultiStatisticsData) render("jobsJobProgress", job.getValueString(ID))).getStatisticsGroupData()).isEmpty();
}
/*******************************************************************************
** Null percent uses rows then chunks, zero is explicit, malformed JSON is safe.
*******************************************************************************/
@Test
void fallbacksAndPartialProgress() throws Exception
{
enableProgress();
QRecord job = job(USER_A, RUNNING, null);
update(job, new QRecord().withValue(CURRENT_PHASE, "copy").withValue(PROGRESS_JSON, "bad json").withValue(BYTES_TOTAL, 1024L));
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst())
.containsEntry("Progress", "45%").containsEntry("Phase", "copy").containsEntry("Bytes", "1.0 KiB total");
update(job, new QRecord().withValue(ROWS_TOTAL, null).withValue(PROGRESS_JSON, "null"));
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst()).containsEntry("Progress", "40%");
update(job, new QRecord().withValue(PERCENT_DONE, 0).withValue(BYTES_TOTAL, null).withValue(BYTES_DONE, 0L));
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst()).containsEntry("Progress", "0%").containsEntry("Bytes", "0 B");
update(job, new QRecord().withValue(PERCENT_DONE, null).withValue(CHUNKS_TOTAL, null));
assertThat(((TableData) render("jobsHomeActive", null)).getRows().getFirst()).containsEntry("Progress", "Unknown");
assertThat(JobsFormat.bytes(1073741824L)).isEqualTo("1.0 GiB");
assertThat(JobsFormat.bytes(Long.MAX_VALUE)).isEqualTo("8.0 EiB");
}
/*******************************************************************************
** The log is ordered, full width and owner scoped, with no user mutations.
*******************************************************************************/
@Test
void logPanelAndPermissions() throws Exception
{
enableProgress();
QRecord job = job(USER_A, RUNNING, null);
log(job, 1000000002L);
log(job, 1000000001L);
assertThat(instance.getWidget("jobsJobLog").getGridColumns()).isEqualTo(12);
assertThat(instance.getTable(JOBS_JOB).getSections()).extracting(QFieldSection::getWidgetName)
.containsSubsequence("jobsJobProgress", "jobsJobLog", "jobsJobEvents");
login(USER_A, false).withPermission("jobs_job.read");
ChildRecordListData data = (ChildRecordListData) render("jobsJobLog", job.getValueString(ID));
assertThat(data.getQueryOutput().getRecords()).extracting(record -> record.getValueLong(ID)).containsExactly(1000000002L, 1000000001L);
assertThat(data.getOnlyIncludeFieldNames()).containsExactly(LEVEL, LOGGED_AT, PHASE, MESSAGE);
assertThat(data.getCanAddChildRecord()).isFalse();
assertThatThrownBy(() -> new DeleteAction().execute(new DeleteInput().withTableName(JOBS_LOG).withPrimaryKey(1000000001L))).hasMessageContaining("read-only");
assertThatThrownBy(() -> log(job, 1000000003L)).hasMessageContaining("read-only");
login("admin", true).withPermission("jobs_job.read");
assertThatThrownBy(() -> new UpdateAction().execute(new UpdateInput().withTableName(JOBS_LOG)
.withRecord(new QRecord().withValue(ID, 1000000001L).withValue(MESSAGE, "changed")))).hasMessageContaining("read-only");
login(USER_B, false).withPermission("jobs_job.read");
assertThatThrownBy(() -> render("jobsJobLog", job.getValueString(ID))).hasMessageContaining("Could not find");
}
/*******************************************************************************
** The system purge crosses a full batch with LONG IDs; active logs remain.
*******************************************************************************/
@Test
void purgeLogBatchesWithSystemPermission() throws Exception
{
enableProgress();
QRecord old = job(USER_A, COMPLETED, now.minus(31, ChronoUnit.DAYS));
QRecord active = job(USER_B, RUNNING, null);
for(long id = 1000000000L; id < 1000000205L; id++)
{
log(old, id);
}
log(active, 1000000300L);
QRecord held = job(USER_A, COMPLETED, now.minus(31, ChronoUnit.DAYS));
effect(held, JobsCoreContract.PENDING, false);
log(held, 1000000301L);
var caller = login("admin", true);
new JobsPurgeFinishedStep().run(new RunBackendStepInput(), new RunBackendStepOutput());
assertThat(QContext.getQSession()).isSameAs(caller);
QContext.setQSession(new QSystemUserSession());
assertThat(records(JOBS_LOG)).extracting(record -> record.getValue(JOB_ID)).containsExactlyInAnyOrder(active.getValue(ID), held.getValue(ID));
assertThat(records(JOBS_JOB)).extracting(record -> record.getValue(ID)).containsExactlyInAnyOrder(active.getValue(ID), held.getValue(ID));
}
/*******************************************************************************
** Log metadata is optional; hosts choose the Admin/Operations parent.
*******************************************************************************/
@Test
void optionalLogAndHostNavigation() throws Exception
{
assertThat(instance.getWidget("jobsJobLog")).as("core RC.8 defines jobs_log, so the Log list is produced").isNotNull();
enableProgress();
instance.getJoins().remove(JobsCoreContract.childJoin(JOBS_LOG));
instance.getWidgets().remove("jobsJobLog");
clearPresentation();
new JobsQBitProducer().produce(instance);
assertThat(instance.getWidget("jobsJobLog")).isNull();
QAppMetaData admin = new QAppMetaData().withName("admin").withLabel("Admin");
QAppMetaData operations = new QAppMetaData().withName("operations").withLabel("Operations");
operations.setParentAppName("admin");
instance.addApp(admin.withChild(operations));
instance.addApp(operations);
config.withAppName("backgroundJobs").withParentAppName("operations");
clearPresentation();
new JobsQBitProducer().withQBitConfig(config).produce(instance);
assertThat(instance.getApp("backgroundJobs").getParentAppName()).isEqualTo("operations");
assertThat(operations.getChildren()).extracting(child -> child.getName()).containsExactly("backgroundJobs");
config.setParentAppName("missing");
assertThatThrownBy(() -> config.validate(instance)).hasMessageContaining("parentAppName");
config.setParentAppName("backgroundJobs");
assertThatThrownBy(() -> config.validate(instance)).hasMessageContaining("parentAppName");
config.setParentAppName(null);
config.validate(instance);
}
/*******************************************************************************
** Rebuild presentation against changed optional metadata in these tests.
*******************************************************************************/
private void clearPresentation()
{
instance.getQBits().clear();
instance.getWidgets().clear();
instance.getApps().remove("jobs");
instance.getProcesses().remove(JobsQBitProducer.PURGE_PROCESS);
instance.getTable(JOBS_JOB).getSections().removeIf(section -> section.getWidgetName() != null);
}
/*******************************************************************************
** Persist progress through core actions.
*******************************************************************************/
private void update(QRecord job, QRecord values) throws Exception
{
var output = new UpdateAction().execute(new UpdateInput().withTableName(JOBS_JOB).withRecord(values.withValue(ID, job.getValue(ID))));
assertThat(output.getRecords().getFirst().getErrors()).isNullOrEmpty();
}
/*******************************************************************************
** Explicit LONG cursors make ordering assertions deterministic. Core memory
** sequence bookkeeping currently restricts inserts to the integer range.
*******************************************************************************/
private void log(QRecord job, long id) throws Exception
{
insert(JOBS_LOG, new QRecord().withValue(ID, id).withValue(JOB_ID, job.getValue(ID)).withValue(LEVEL, "INFO")
.withValue(LOGGED_AT, now).withValue(PHASE, "upload").withValue(MESSAGE, "Wrote manifest"));
}
}
@@ -30,21 +30,33 @@ import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaDataProvider;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import org.junit.jupiter.api.Test;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ALERT_EFFECT_UNRESOLVED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ALERT_JOB_FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ALERT_JOB_STALLED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ATTEMPT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNK_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.HEARTBEAT_REAPER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT_STATUS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT_TYPE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_UPLOAD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_WORKER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEASE_VERSION;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW_FIELD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RECONCILE_ATTEMPTS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -54,6 +66,17 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
*******************************************************************************/
class JobsQBitProducerTest extends BaseJobsTest
{
/*******************************************************************************
** The real kernel configuration passes startup validation without waivers.
*******************************************************************************/
@Test
void durableJobsMetadataValidates()
{
QInstanceValidator validator = new QInstanceValidator();
DurableJobsMetaData.of(instance).validate(instance, validator);
assertThat(validator.getErrors()).isEmpty();
}
/*******************************************************************************
** Fail before registering anything when the kernel has not been enabled.
*******************************************************************************/
@@ -74,13 +97,13 @@ class JobsQBitProducerTest extends BaseJobsTest
@Test
void navigationWidgetsAndRecordSections()
{
assertThat(instance.getTables()).hasSize(7);
assertThat(instance.getTables()).hasSize(12);
assertThat(instance.getApp("jobs").getChildren()).extracting(child -> child.getName()).containsExactly(JOBS_JOB);
assertThat(instance.getApp("jobs").getWidgets()).containsExactlyElementsOf(JobsQBitProducer.HOME_WIDGETS);
assertThat(instance.getWidgets()).hasSize(11);
assertThat(instance.getWidgets()).hasSize(13);
assertThat(instance.getWidgets().values()).allSatisfy(widget -> assertThat(widget.getType()).isNotEqualTo(WidgetType.HTML.getType()));
assertThat(instance.getTable(JOBS_JOB).getSections()).extracting(QFieldSection::getWidgetName)
.contains("jobsJobProgress", "jobsJobChunks", "jobsJobRowErrors", "jobsJobEvents", "jobsJobAudit");
.containsSubsequence("jobsJobProgress", "jobsJobLog", "jobsJobChunks", "jobsJobRowErrors", "jobsJobEffects", "jobsJobEvents", "jobsJobAudit");
for(String name : JobsQBitProducer.HOME_WIDGETS)
{
assertThat(instance.getWidget(name).getDefaultValues()).containsEntry("refreshSeconds", 5);
@@ -93,7 +116,7 @@ class JobsQBitProducerTest extends BaseJobsTest
assertThat(metadata.getGroupId()).isEqualTo("com.kingsrook.qbits");
assertThat(metadata.getArtifactId()).isEqualTo("qbit-jobs");
assertThat(metadata.getNamespace()).isEqualTo("jobs");
assertThat(metadata.getVersion()).isEqualTo("0.1.0-SNAPSHOT");
assertThat(metadata.getVersion()).matches("\\d+\\.\\d+\\.\\d+(-SNAPSHOT|-RC\\.\\d+)?");
}
/*******************************************************************************
@@ -103,12 +126,13 @@ class JobsQBitProducerTest extends BaseJobsTest
void schedulerAndAuditAreOptional() throws Exception
{
QInstance other = new QInstance();
JobsTablesStandIn.defineAll(other);
new DurableJobsMetaDataProvider().defineAll(other, "memory", null);
other.withSupplementalMetaData(new DurableJobsMetaData());
JobsQBitConfig custom = new JobsQBitConfig().withAppName("backgroundJobs").withSchedulerName("scheduler")
.withPurgeCronExpression("0 0 4 * * ?").withHomeRefreshSeconds(9).withRecordRefreshSeconds(8);
new JobsQBitProducer().withQBitConfig(custom).produce(other);
assertThat(other.getWidget("jobsJobAudit")).isNull();
assertThat(other.getWidgets()).hasSize(10);
assertThat(other.getWidgets()).hasSize(12);
assertThat(other.getApp("backgroundJobs")).isNotNull();
QScheduleMetaData schedule = other.getProcess(JobsQBitProducer.PURGE_PROCESS).getSchedule();
assertThat(schedule.getSchedulerName()).isEqualTo("scheduler");
@@ -129,6 +153,11 @@ class JobsQBitProducerTest extends BaseJobsTest
assertThat(failed.getSubjectTableName()).isEqualTo(JOBS_JOB);
assertThat(failed.getHowToFix()).isNotBlank();
assertThat(QAlertTypeMetaData.of(instance, ALERT_JOB_STALLED).getDefaultSeverity()).isEqualTo(AlertSeverity.WARNING);
QAlertTypeMetaData effect = QAlertTypeMetaData.of(instance, ALERT_EFFECT_UNRESOLVED);
assertThat(effect.getLabel()).isEqualTo("Job effect needs review");
assertThat(effect.getDefaultSeverity()).isEqualTo(AlertSeverity.WARNING);
assertThat(effect.getSubjectTableName()).isEqualTo(JOBS_JOB);
assertThat(effect.getHowToFix()).isNotBlank();
QAlertTypeMetaData reaper = QAlertTypeMetaData.of(instance, HEARTBEAT_REAPER);
assertThat(reaper.getLabel()).isEqualTo("Job reaper stopped");
assertThat(reaper.getDefaultMonitors()).singleElement().satisfies(monitor ->
@@ -177,7 +206,7 @@ class JobsQBitProducerTest extends BaseJobsTest
{
assertThat(JobsIntegrations.esbTableNames()).containsExactly(JOBS_JOB);
assertThat(JobsIntegrations.quickSearchTables()).containsExactly(JOBS_JOB);
assertThat(JobsIntegrations.dataAnalysisTables()).containsExactly(JOBS_JOB, JOBS_CHUNK, JOBS_ROW_ERROR, JOBS_EVENT, JOBS_WORKER);
assertThat(JobsIntegrations.dataAnalysisTables()).containsExactly(JOBS_JOB, JOBS_CHUNK, JOBS_ROW_ERROR, JOBS_EFFECT, JOBS_EVENT, JOBS_WORKER);
}
/*******************************************************************************
@@ -189,9 +218,49 @@ class JobsQBitProducerTest extends BaseJobsTest
AlertsInstanceMetaData alerts = new AlertsInstanceMetaData();
alerts.withAlertType(QAlertTypeMetaData.of(instance, ALERT_JOB_FAILED));
alerts.withAlertType(QAlertTypeMetaData.of(instance, ALERT_JOB_STALLED));
alerts.withAlertType(QAlertTypeMetaData.of(instance, ALERT_EFFECT_UNRESOLVED));
QInstanceValidator validator = new QInstanceValidator();
alerts.validate(instance, validator);
assertThat(validator.getErrors()).isEmpty();
}
/*******************************************************************************
** Hosts can omit the effects panel by removing its table or join.
*******************************************************************************/
@Test
void effectsTableAndJoinAreOptional() throws Exception
{
QInstance withoutTable = new QInstance();
new DurableJobsMetaDataProvider().defineAll(withoutTable, "memory", null);
withoutTable.withSupplementalMetaData(new DurableJobsMetaData());
withoutTable.getTables().remove(JOBS_EFFECT);
new JobsQBitProducer().produce(withoutTable);
assertThat(withoutTable.getWidget("jobsJobEffects")).isNull();
QInstance withoutJoin = new QInstance();
new DurableJobsMetaDataProvider().defineAll(withoutJoin, "memory", null);
withoutJoin.withSupplementalMetaData(new DurableJobsMetaData());
withoutJoin.getJoins().remove(JobsCoreContract.childJoin(JOBS_EFFECT));
new JobsQBitProducer().produce(withoutJoin);
assertThat(withoutJoin.getWidget("jobsJobEffects")).isNull();
}
/*******************************************************************************
** Outbox metadata carries the core field defaults, references and labels.
*******************************************************************************/
@Test
void effectsCoreContract() throws Exception
{
assertThat(instance.getTable(JOBS_EFFECT).getFields()).hasSize(26);
assertThat(instance.getTable(JOBS_EFFECT).getField(JOB_ID).getPossibleValueSourceName()).isEqualTo(JOBS_JOB);
assertThat(instance.getTable(JOBS_EFFECT).getField(CHUNK_ID).getPossibleValueSourceName()).isEqualTo(JOBS_CHUNK);
assertThat(instance.getTable(JOBS_EFFECT).getField(NEEDS_REVIEW_FIELD).getDefaultValue()).isEqualTo(false);
for(String field : List.of(ATTEMPT, RECONCILE_ATTEMPTS, LEASE_VERSION))
{
assertThat(instance.getTable(JOBS_EFFECT).getField(field).getDefaultValue()).isEqualTo(0);
}
assertThat(instance.getPossibleValueSource(JOBS_EFFECT_STATUS).getEnumValues()).extracting(value -> value.getLabel())
.containsExactly("Pending", "Sending", "Sent", "Failed", "Unknown");
assertThat(instance.getPossibleValueSource(JOBS_EFFECT_TYPE).getEnumValues()).extracting(value -> value.getLabel())
.containsExactly("Local Transaction", "Idempotent External", "Non-Idempotent External");
}
}
@@ -30,12 +30,16 @@ import com.kingsrook.qbits.jobs.processes.JobsStorage;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsMetaData;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsSecurity;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
@@ -48,17 +52,23 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.INPUT_STORAGE_REF;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_UPLOAD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW_FIELD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PENDING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RUNNING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SENDING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SEQ;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STATUS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.TERMINAL_STATUSES;
import static com.kingsrook.qbits.jobs.JobsCoreContract.UNKNOWN;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -81,18 +91,19 @@ class JobsRetentionTest extends BaseJobsTest
children(job(USER_B, RUNNING, now.minus(40, ChronoUnit.DAYS)));
children(job(USER_A, COMPLETED, now.minus(2, ChronoUnit.DAYS)));
children(job(USER_A, NEEDS_REVIEW, now.minus(40, ChronoUnit.DAYS)));
List<Integer> auditIds = records(AUDIT).stream().map(record -> record.getValueInteger(ID)).toList();
RecordingStorage storage = new RecordingStorage();
QSession caller = login("admin", true);
List<ProcessSummaryLine> summary = run(new JobsPurgeFinishedStep(storage));
assertThat(QContext.getQSession()).isSameAs(caller);
assertThat(summary).extracting(ProcessSummaryLine::getCount).containsExactly(4, 4, 4);
JobsSecurity.ensureSessionKeys(caller);
DurableJobsSecurity.ensureSessionKeys(caller);
assertThat(records(JOBS_JOB)).hasSize(3).extracting(record -> record.getValueString(STATUS)).containsExactly(RUNNING, COMPLETED, NEEDS_REVIEW);
for(String table : List.of(JOBS_CHUNK, JOBS_ROW_ERROR, JOBS_EVENT, JOBS_UPLOAD))
{
assertThat(records(table)).hasSize(3);
}
assertThat(records(AUDIT)).hasSize(7);
assertThat(records(AUDIT)).extracting(record -> record.getValueInteger(ID)).containsAll(auditIds);
assertThat(storage.deleted).hasSize(8);
assertThat(run(new JobsPurgeFinishedStep(storage))).extracting(ProcessSummaryLine::getCount).containsExactly(0, 0, 0);
}
@@ -149,20 +160,30 @@ class JobsRetentionTest extends BaseJobsTest
}
/*******************************************************************************
** RC.5 safely skips unavailable object deletion and preserves retry references.
** Core storage deletes input objects before retention clears their references.
*******************************************************************************/
@Test
void releasedCoreSkipsObjectDeletion() throws Exception
void releasedCoreDeletesObjects() throws Exception
{
QRecord clean = job(USER_A, COMPLETED, now.minus(8, ChronoUnit.DAYS));
clearFailures(clean);
children(clean);
instance.withSupplementalMetaData(new StorageMetadata());
assertThat(run(new JobsPurgeFinishedStep())).extracting(ProcessSummaryLine::getCount).containsExactly(0, 0, 0);
instance.addTable(new QTableMetaData().withName("objects").withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.STRING)).withField(new QFieldMetaData("data", QFieldType.BLOB)));
instance.withSupplementalMetaData(new DurableJobsMetaData().withStorageTableName("objects"));
QSession caller = QContext.getQSession();
assertThatThrownBy(() -> run(new JobsPurgeFinishedStep())).isInstanceOf(QException.class);
assertThat(QContext.getQSession()).isSameAs(caller);
assertThat(records(JOBS_CHUNK).getFirst().getValue(INPUT_STORAGE_REF)).isNotNull();
insert("objects", new QRecord().withValue(ID, "chunk/" + clean.getValue(ID)).withValue("data", new byte[] {1}));
assertThat(run(new JobsPurgeFinishedStep())).extracting(ProcessSummaryLine::getCount).containsExactly(0, 0, 1);
assertThat(records(JOBS_CHUNK).getFirst().getValue(INPUT_STORAGE_REF)).isNull();
JobsStorage storage = new JobsStorage();
assertThat(storage.chunkStorageTable()).isEqualTo("objects");
assertThat(storage.delete("objects", "another-object")).isFalse();
assertThat(records("objects")).isEmpty();
assertThatThrownBy(() -> storage.delete("objects", "missing-object")).isInstanceOf(QException.class);
assertThat(storage.delete("", "object")).isFalse();
assertThat(storage.delete("objects", " ")).isFalse();
assertThat(storage.delete(null, "object")).isFalse();
assertThat(storage.delete("objects", null)).isFalse();
}
@@ -179,7 +200,7 @@ class JobsRetentionTest extends BaseJobsTest
storage.fail = true;
assertThatThrownBy(() -> run(new JobsPurgeFinishedStep(storage))).hasMessageContaining("storage unavailable");
assertThat(QContext.getQSession()).isSameAs(caller);
JobsSecurity.ensureSessionKeys(caller);
DurableJobsSecurity.ensureSessionKeys(caller);
assertThat(records(JOBS_JOB)).hasSize(1);
assertThat(records(JOBS_CHUNK)).hasSize(1);
storage.fail = false;
@@ -203,10 +224,73 @@ class JobsRetentionTest extends BaseJobsTest
@Test
void absentStorageMetadata() throws Exception
{
instance.getSupplementalMetaData().remove(DURABLE_JOBS_METADATA);
assertThat(new JobsStorage().chunkStorageTable()).isNull();
assertThat(run(new JobsPurgeFinishedStep())).extracting(ProcessSummaryLine::getCount).containsExactly(0, 0, 0);
}
/*******************************************************************************
** Unsettled effects preserve every child and input; settled effects are paged away.
*******************************************************************************/
@Test
void effectsBlockPurgeUntilSettled() throws Exception
{
List<QRecord> kept = new ArrayList<>();
for(String status : List.of(PENDING, SENDING, UNKNOWN))
{
QRecord job = job(USER_A, COMPLETED, now.minus(40, ChronoUnit.DAYS));
clearFailures(job);
children(job);
effect(job, status, false);
kept.add(job);
}
QRecord review = job(USER_A, FAILED, now.minus(40, ChronoUnit.DAYS));
children(review);
effect(review, FAILED, true);
kept.add(review);
QRecord settled = job(USER_B, COMPLETED, now.minus(40, ChronoUnit.DAYS));
children(settled);
for(int i = 0; i < 205; i++)
{
effect(settled, i % 2 == 0 ? SENT : FAILED, false);
}
RecordingStorage storage = new RecordingStorage();
QSession caller = login("admin", true);
List<ProcessSummaryLine> summary = run(new JobsPurgeFinishedStep(storage));
assertThat(QContext.getQSession()).isSameAs(caller);
assertThat(summary).extracting(ProcessSummaryLine::getCount).containsExactly(1, 1, 1, 4);
assertThat(summary.getLast().getMessage()).isEqualTo("jobs kept: effects still unsettled");
DurableJobsSecurity.ensureSessionKeys(caller);
assertThat(records(JOBS_JOB)).extracting(record -> record.getValue(ID)).containsExactlyElementsOf(kept.stream().map(record -> record.getValue(ID)).toList());
assertThat(records(JOBS_EFFECT)).hasSize(4);
for(String table : List.of(JOBS_CHUNK, JOBS_ROW_ERROR, JOBS_EVENT, JOBS_UPLOAD))
{
assertThat(records(table)).hasSize(4);
}
assertThat(records(JOBS_CHUNK)).allSatisfy(chunk -> assertThat(chunk.getValue(INPUT_STORAGE_REF)).isNotNull());
assertThat(storage.deleted).containsExactly("objects:chunk/" + settled.getValue(ID), "objects:input/" + settled.getValue(ID));
for(QRecord effect : records(JOBS_EFFECT))
{
new UpdateAction().execute(new UpdateInput().withTableName(JOBS_EFFECT).withRecord(new QRecord()
.withValue(ID, effect.getValue(ID)).withValue(STATUS, SENT).withValue(NEEDS_REVIEW_FIELD, false)));
}
assertThat(run(new JobsPurgeFinishedStep(storage))).extracting(ProcessSummaryLine::getCount).containsExactly(4, 4, 4);
assertThat(records(JOBS_JOB)).isEmpty();
assertThat(records(JOBS_EFFECT)).isEmpty();
}
/*******************************************************************************
** Retention tolerates a host omitting the effects table.
*******************************************************************************/
@Test
void purgeWithoutEffectsTable() throws Exception
{
instance.getTables().remove(JOBS_EFFECT);
children(job(USER_A, FAILED, now.minus(40, ChronoUnit.DAYS)));
assertThat(run(new JobsPurgeFinishedStep(new RecordingStorage()))).extracting(ProcessSummaryLine::getCount).containsExactly(1, 1, 1);
assertThat(records(JOBS_JOB)).isEmpty();
}
/*******************************************************************************
** Mark the test job successful with no failed rows.
*******************************************************************************/
@@ -228,7 +312,7 @@ class JobsRetentionTest extends BaseJobsTest
}
/*******************************************************************************
** Test storage models successful RC.6 object deletion, without using disk.
** Test storage records deletions and simulates failures without using disk.
*******************************************************************************/
private static class RecordingStorage extends JobsStorage
{
@@ -264,26 +348,4 @@ class JobsRetentionTest extends BaseJobsTest
}
}
/*******************************************************************************
** Test-only supplemental metadata for the RC.5 reflection bridge.
*******************************************************************************/
public static class StorageMetadata implements QSupplementalInstanceMetaData
{
/*******************************************************************************
** Core metadata contract name.
*******************************************************************************/
@Override
public String getName()
{
return (DURABLE_JOBS_METADATA);
}
/*******************************************************************************
** Future core getter discovered reflectively by the adapter.
*******************************************************************************/
public String getStorageTableName()
{
return ("objects");
}
}
}
@@ -22,6 +22,7 @@
package com.kingsrook.qbits.jobs;
import com.kingsrook.qqq.backend.core.jobs.DurableJobsSecurity;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
@@ -45,13 +46,13 @@ class JobsSecurityTest
{
QSession session = new QSession().withUser(new QUser().withIdReference("user"))
.withSecurityKeyValue(JOBS_USER, "wrong").withSecurityKeyValue(JOBS_USER_ALL_ACCESS, true);
JobsSecurity.applyToSession(session);
DurableJobsSecurity.applyToSession(session);
assertThat(session.getSecurityKeyValues(JOBS_USER)).containsExactly("user");
assertThat(session.getSecurityKeyValues(JOBS_USER_ALL_ACCESS)).isEmpty();
session.withPermission(JOBS_ADMIN);
JobsSecurity.applyToSession(session);
DurableJobsSecurity.applyToSession(session);
assertThat(session.getSecurityKeyValues(JOBS_USER_ALL_ACCESS)).containsExactly(true);
JobsSecurity.ensureSessionKeys(session);
DurableJobsSecurity.ensureSessionKeys(session);
assertThat(session.getSecurityKeyValues(JOBS_USER)).containsExactly("user");
}
@@ -61,15 +62,15 @@ class JobsSecurityTest
@Test
void nullAnonymousAndSystem()
{
JobsSecurity.applyToSession(null);
JobsSecurity.ensureSessionKeys(null);
JobsSecurity.applyToSession(new QSystemUserSession());
JobsSecurity.ensureSessionKeys(new QSystemUserSession());
assertThat(JobsSecurity.userId(null)).isNull();
DurableJobsSecurity.applyToSession(null);
DurableJobsSecurity.ensureSessionKeys(null);
DurableJobsSecurity.applyToSession(new QSystemUserSession());
DurableJobsSecurity.ensureSessionKeys(new QSystemUserSession());
assertThat(DurableJobsSecurity.userId(null)).isNull();
QSession anonymous = new QSession();
JobsSecurity.ensureSessionKeys(anonymous);
assertThat(JobsSecurity.userId(anonymous)).isNull();
DurableJobsSecurity.ensureSessionKeys(anonymous);
assertThat(DurableJobsSecurity.userId(anonymous)).isNull();
assertThat(anonymous.getSecurityKeyValues(JOBS_USER)).isEmpty();
JobsSecurity.ensureSessionKeys(new QSession().withUser(new QUser().withIdReference("")));
DurableJobsSecurity.ensureSessionKeys(new QSession().withUser(new QUser().withIdReference("")));
}
}
@@ -1,278 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2026. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.jobs;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ACTOR_USER_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ATTEMPT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHECKPOINT_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNKS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNK_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CHUNK_SIZE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CLIENT_CHANNEL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CONTENT_TYPE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CREATE_DATE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.CREDENTIAL_SCOPE_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DETAIL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.DRAINING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ERROR_CODE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ERROR_MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.EXPIRES_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FIELD_NAME;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FILE_NAME;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FINISHED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.HEARTBEAT_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.IDEMPOTENCY;
import static com.kingsrook.qbits.jobs.JobsCoreContract.INPUT_STORAGE_REF;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_ROW_ERROR;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_UPLOAD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_USER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_USER_ALL_ACCESS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_WORKER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LABEL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LAST_PROGRESS_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEASE_EXPIRES_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEASE_OWNER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEASE_TOKEN;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LEASE_VERSION;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MAX_ATTEMPTS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MAX_PARALLEL_CHUNKS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MESSAGE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MODE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.MODIFY_DATE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NOTIFY_EMAIL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.OUTPUT_STORAGE_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PARAMS_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PARENT_JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.POD;
import static com.kingsrook.qbits.jobs.JobsCoreContract.POD_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PRIORITY;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROCESS_NAME;
import static com.kingsrook.qbits.jobs.JobsCoreContract.QUEUED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RANGE_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RECORD_PK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.REF;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RESOLVED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RETRY_JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_DONE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_IN;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_OK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROW_NUMBER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SAVED_BULK_LOAD_PROFILE_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SEMANTICS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SEQ;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SIZE_BYTES;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SOURCE_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SOURCE_TYPE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STARTED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STATUS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STATUS_REASON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STORAGE_REFERENCE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STORAGE_TABLE_NAME;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SUMMARY_JSON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.TABLE_NAME;
import static com.kingsrook.qbits.jobs.JobsCoreContract.THREADS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.TYPE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.USER_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.VERSION;
/*******************************************************************************
** STAND-IN(core=QRun-IO/qqq#1178, removeBy=RC.6): test-only mirror of the core
** tables, field types, primary keys, owner locks and child joins. Includes uploads
** so retention is exercised against the complete six-table contract.
*******************************************************************************/
public final class JobsTablesStandIn
{
/*******************************************************************************
** No instances.
*******************************************************************************/
private JobsTablesStandIn()
{
}
/*******************************************************************************
** Define the core contract on a memory backend without importing core.jobs.
*******************************************************************************/
public static void defineAll(QInstance instance) throws QException
{
instance.addSecurityKeyType(new QSecurityKeyType().withName(JOBS_USER).withAllAccessKeyName(JOBS_USER_ALL_ACCESS));
instance.addTable(new QTableMetaData().withName(JOBS_JOB).withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(LABEL, QFieldType.STRING).withIsEditable(true))
.withField(new QFieldMetaData(PROCESS_NAME, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(TABLE_NAME, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(MODE, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(STATUS, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(STATUS_REASON, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(PRIORITY, QFieldType.INTEGER).withIsEditable(true))
.withField(new QFieldMetaData(USER_ID, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(CLIENT_CHANNEL, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(CREDENTIAL_SCOPE_JSON, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(SOURCE_TYPE, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(SOURCE_JSON, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(PARAMS_JSON, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(IDEMPOTENCY, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(SEMANTICS, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(CHUNK_SIZE, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(MAX_PARALLEL_CHUNKS, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(ROWS_TOTAL, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(ROWS_DONE, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(ROWS_OK, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(ROWS_FAILED, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(CHUNKS_TOTAL, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(CHUNKS_DONE, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(CHUNKS_FAILED, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(SUMMARY_JSON, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(OUTPUT_STORAGE_JSON, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(PARENT_JOB_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(SAVED_BULK_LOAD_PROFILE_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(NOTIFY_EMAIL, QFieldType.BOOLEAN).withIsEditable(true))
.withField(new QFieldMetaData(QUEUED_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(STARTED_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(FINISHED_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(LAST_PROGRESS_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(ERROR_MESSAGE, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(LEASE_VERSION, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(CREATE_DATE, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(MODIFY_DATE, QFieldType.DATE_TIME).withIsEditable(false)));
instance.addTable(new QTableMetaData().withName(JOBS_CHUNK).withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(JOB_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(SEQ, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(STATUS, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(RANGE_JSON, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(INPUT_STORAGE_REF, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(ATTEMPT, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(MAX_ATTEMPTS, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(LEASE_OWNER, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(LEASE_TOKEN, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(LEASE_EXPIRES_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(HEARTBEAT_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(ROWS_IN, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(ROWS_OK, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(ROWS_FAILED, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(SUMMARY_JSON, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(CHECKPOINT_JSON, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(ERROR_MESSAGE, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(STARTED_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(FINISHED_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(LEASE_VERSION, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(CREATE_DATE, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(MODIFY_DATE, QFieldType.DATE_TIME).withIsEditable(false)));
instance.addTable(new QTableMetaData().withName(JOBS_ROW_ERROR).withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(JOB_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(CHUNK_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(ROW_NUMBER, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(RECORD_PK, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(FIELD_NAME, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(ERROR_CODE, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(MESSAGE, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(RETRY_JOB_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(RESOLVED, QFieldType.BOOLEAN).withIsEditable(true))
.withField(new QFieldMetaData(CREATE_DATE, QFieldType.DATE_TIME).withIsEditable(false)));
instance.addTable(new QTableMetaData().withName(JOBS_EVENT).withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(JOB_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(CHUNK_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(TYPE, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(ACTOR_USER_ID, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(CLIENT_CHANNEL, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(POD, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(DETAIL, QFieldType.TEXT).withIsEditable(false))
.withField(new QFieldMetaData(CREATE_DATE, QFieldType.DATE_TIME).withIsEditable(false)));
instance.addTable(new QTableMetaData().withName(JOBS_WORKER).withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(POD_ID, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(STARTED_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(HEARTBEAT_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(THREADS, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(DRAINING, QFieldType.BOOLEAN).withIsEditable(false))
.withField(new QFieldMetaData(VERSION, QFieldType.STRING).withIsEditable(false)));
instance.addTable(new QTableMetaData().withName(JOBS_UPLOAD).withBackendName("memory").withPrimaryKeyField(ID)
.withField(new QFieldMetaData(ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(REF, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(USER_ID, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(FILE_NAME, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(CONTENT_TYPE, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(SIZE_BYTES, QFieldType.LONG).withIsEditable(false))
.withField(new QFieldMetaData(STORAGE_TABLE_NAME, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(STORAGE_REFERENCE, QFieldType.STRING).withIsEditable(false))
.withField(new QFieldMetaData(EXPIRES_AT, QFieldType.DATE_TIME).withIsEditable(false))
.withField(new QFieldMetaData(JOB_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(CREATE_DATE, QFieldType.DATE_TIME).withIsEditable(false)));
instance.getTable(JOBS_JOB).withSection(new QFieldSection()
.withName("fields").withLabel("Job").withTier(Tier.T1)
.withFieldNames(new ArrayList<>(instance.getTable(JOBS_JOB).getFields().keySet())));
instance.getTable(JOBS_JOB).withRecordSecurityLock(lock(USER_ID, null));
instance.getTable(JOBS_UPLOAD).withRecordSecurityLock(lock(USER_ID, null));
for(String child : List.of(JOBS_CHUNK, JOBS_ROW_ERROR, JOBS_EVENT))
{
String join = JobsCoreContract.childJoin(child);
instance.addJoin(new QJoinMetaData().withLeftTable(JOBS_JOB).withRightTable(child).withType(JoinType.ONE_TO_MANY)
.withJoinOn(new JoinOn(ID, JOB_ID)).withInferredName());
instance.getTable(child).withRecordSecurityLock(lock(JOBS_JOB + "." + USER_ID, List.of(join)));
}
instance.getTable(JOBS_CHUNK).withUniqueKey(new UniqueKey(JOB_ID, SEQ));
instance.getTable(JOBS_WORKER).withUniqueKey(new UniqueKey(POD_ID));
instance.getTable(JOBS_UPLOAD).withUniqueKey(new UniqueKey(REF));
}
/*******************************************************************************
** The same read/write owner lock as the kernel, including join traversal.
*******************************************************************************/
private static RecordSecurityLock lock(String field, List<String> joins)
{
RecordSecurityLock lock = new RecordSecurityLock().withSecurityKeyType(JOBS_USER).withFieldName(field)
.withLockScope(RecordSecurityLock.LockScope.READ_AND_WRITE);
if(joins != null)
{
lock.withJoinNameChain(joins);
}
return (lock);
}
}
@@ -58,11 +58,14 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.FINISHED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_CHUNK;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EFFECT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_EVENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_JOB;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOBS_USER;
import static com.kingsrook.qbits.jobs.JobsCoreContract.JOB_ID;
import static com.kingsrook.qbits.jobs.JobsCoreContract.LAST_PROGRESS_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.NEEDS_REVIEW;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PENDING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PRIORITY;
import static com.kingsrook.qbits.jobs.JobsCoreContract.PROCESS_NAME;
import static com.kingsrook.qbits.jobs.JobsCoreContract.QUEUED;
@@ -70,11 +73,14 @@ import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_FAILED;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_IN;
import static com.kingsrook.qbits.jobs.JobsCoreContract.ROWS_TOTAL;
import static com.kingsrook.qbits.jobs.JobsCoreContract.RUNNING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SENDING;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SENT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.SEQ;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STARTED_AT;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STATUS;
import static com.kingsrook.qbits.jobs.JobsCoreContract.STATUS_REASON;
import static com.kingsrook.qbits.jobs.JobsCoreContract.TYPE;
import static com.kingsrook.qbits.jobs.JobsCoreContract.UNKNOWN;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -414,6 +420,81 @@ class JobsWidgetRendererTest extends BaseJobsTest
.hasMessageContaining("Unknown Jobs widget");
}
/*******************************************************************************
** Review counts belong only to listed jobs; core controls attention eligibility.
*******************************************************************************/
@Test
void attentionEffectsAndOwnerIsolation() throws Exception
{
QRecord review = job(USER_A, NEEDS_REVIEW, now);
effect(review, UNKNOWN, true);
effect(review, UNKNOWN, true);
effect(review, PENDING, false);
effect(job(USER_A, RUNNING, now), UNKNOWN, true);
QRecord other = job(USER_B, FAILED, now.minusSeconds(60));
effect(other, UNKNOWN, true);
QSession viewer = login(USER_A, false);
TableData attention = (TableData) render("jobsHomeAttention", null);
assertThat(attention.getRows()).hasSize(1);
assertThat(texts((CompositeWidgetData) attention.getRows().getFirst().get(JobsWidgetRenderer.STATUS_CELL))).contains("2 effects need review");
assertThat(QContext.getQSession()).isSameAs(viewer);
login(USER_B, false);
attention = (TableData) render("jobsHomeAttention", null);
assertThat(attention.getRows()).hasSize(1);
assertThat(texts((CompositeWidgetData) attention.getRows().getFirst().get(JobsWidgetRenderer.STATUS_CELL)))
.contains("1 effects need review").doesNotContain("2 effects need review");
assertThat(records(JOBS_EFFECT)).hasSize(1).allSatisfy(record -> assertThat(record.getValue(JOB_ID)).isEqualTo(other.getValue(ID)));
}
/*******************************************************************************
** Effects use disjoint statistics and the secured core child renderer.
*******************************************************************************/
@Test
void effectsProgressAndChildList() throws Exception
{
QRecord job = job(USER_A, NEEDS_REVIEW, now);
for(String status : List.of(SENT, SENT, PENDING, SENDING, UNKNOWN, FAILED))
{
effect(job, status, false);
}
QRecord firstReview = effect(job, UNKNOWN, true);
effect(job, PENDING, true);
effect(job(USER_B, RUNNING, null), SENT, false);
String id = job.getValueString(ID);
QSession viewer = login(USER_A, false);
MultiStatisticsData progress = (MultiStatisticsData) render("jobsJobProgress", id);
assertThat(progress.getStatisticsGroupData()).extracting(MultiStatisticsData.StatisticsGroupData::getHeader)
.containsExactly("Rows", "Chunks", "Speed", "Effects");
assertThat(groupStat(progress, "Effects", "Sent")).isEqualTo(2);
assertThat(groupStat(progress, "Effects", "Pending")).isEqualTo(3);
assertThat(groupStat(progress, "Effects", "Failed")).isEqualTo(1);
assertThat(groupStat(progress, "Effects", "Needs review")).isEqualTo(2);
ChildRecordListData effects = (ChildRecordListData) render("jobsJobEffects", id);
assertThat(effects.getTotalRows()).isEqualTo(8);
assertThat(effects.getQueryOutput().getRecords()).extracting(record -> record.getValueInteger(ID)).isSorted();
assertThat(effects.getQueryOutput().getRecords()).extracting(record -> record.getValue(ID)).contains(firstReview.getValue(ID));
assertThat(QContext.getQSession()).isSameAs(viewer);
login(USER_B, false);
assertThat(((MultiStatisticsData) render("jobsJobProgress", id)).getStatisticsGroupData()).isEmpty();
assertThatThrownBy(() -> render("jobsJobEffects", id)).isInstanceOf(QException.class);
login("admin", true);
assertThat(((ChildRecordListData) render("jobsJobEffects", id)).getTotalRows()).isEqualTo(8);
}
/*******************************************************************************
** Attention and progress tolerate a host omitting the effects table.
*******************************************************************************/
@Test
void widgetsWithoutEffectsTable() throws Exception
{
QRecord job = job(USER_A, FAILED, now);
instance.getTables().remove(JOBS_EFFECT);
login(USER_A, false);
assertThat(((TableData) render("jobsHomeAttention", null)).getRows()).hasSize(1);
assertThat(((MultiStatisticsData) render("jobsJobProgress", job.getValueString(ID))).getStatisticsGroupData())
.extracting(MultiStatisticsData.StatisticsGroupData::getHeader).containsExactly("Rows", "Chunks", "Speed");
}
/*******************************************************************************
** Test fixture update.
*******************************************************************************/