Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20d36c108
|
||
|
|
a992133053
|
||
|
|
4b3ca697e5
|
||
|
|
1a800da73d
|
||
|
|
17a094d427
|
||
|
|
009e940ee6
|
||
|
|
b6c73cc3a5
|
||
|
|
5d4b212b56
|
||
|
|
1828c7f9d8
|
||
|
|
055e33c99b
|
||
|
|
912f1dc3a9
|
||
|
|
cd995254fd
|
||
|
|
da332ce0fb | ||
|
|
22a40ad295 | ||
|
|
c92df228b9 | ||
|
|
5af79ae05d | ||
|
|
469e727884 | ||
|
|
6434453666 | ||
|
|
f2584be8c5 | ||
|
|
43d65e2e58 | ||
|
|
6dbbdacd0e
|
||
|
|
913bd497ed | ||
|
|
8cb487343a
|
||
|
|
b8bc1a7719 | ||
|
|
dd06743824
|
||
|
|
7a1f236c45 | ||
|
|
80b0b66c49
|
||
|
|
e5c6a71493
|
||
|
|
0378647bce | ||
|
|
8ffd6d21ab | ||
|
|
21fb47f74a | ||
|
|
f961191173
|
||
|
|
8506770cbc | ||
|
|
6a1f98aef4 | ||
|
|
eb93f00859 | ||
|
|
73538809f3
|
||
|
|
b9480a2f56
|
||
|
|
bf2322f438 | ||
|
|
d6465a8306 | ||
|
|
dfa9ca5e37
|
No files matched your search
+11
-57
@@ -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+$/
|
||||
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user