Compare commits

...
Author SHA1 Message Date
james.maesandCodex eb429f7cbb ci: centralize QBit source scanning
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:19:34 -05:00
james.maesandCodex d67dec8448 ci: adopt central orb 0.7.12 and stable gates (#71)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:57:06 -05:00
james.maesandCodex fd34b1c6c8 ci: centralize rc release completion in qqq orb (#70)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:29:01 -05:00
james.maesandCodex 00148af810 ci: adopt shared orb 0.7.11 (#69)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:03:35 -05:00
james.maes 15a564258b Merge pull request #68 from QRun-IO/feature/central-ci
ci: adopt verified central QQQ orb 0.7.1
2026-10-20 18:47:45 -05:00
james.maesandCodex 4437720546 ci: adopt verified central QQQ orb 0.7.2
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 18:42:39 -05:00
james.maes e4bf215310 Merge pull request #67 from QRun-IO/feature/single-node-replicas
fix(opensearch): size index replicas to the cluster so a single node stays green
2026-10-16 11:51:24 -05:00
james.maesandClaude Opus 5.5 d76391f6a4 fix(opensearch): size index replicas to the cluster so a single node stays green
The index was created without number_of_replicas, so OpenSearch's default
of 1 left an unassignable replica on single-node clusters: the alias was
yellow and quick_search.opensearch reported DEGRADED.

- New config indexReplicas (default null = automatic:
  min(maxAutoReplicas, data nodes - 1)) and maxAutoReplicas (default 1).
  Zero config gives 0 replicas on one node and 1 on larger clusters.
- New physical indexes get the target; at startup existing indexes are
  reconciled with PUT /<index>/_settings when the value differs
  (idempotent, logged, failure tolerant, never blocks startup). Indexes
  with auto_expand_replicas and OpenSearch Serverless are left alone.
- Status snapshot and quick_search.opensearch health details gain
  unassignedReplicaShards, targetReplicas and replicaSettingsFailed.
  Yellow on a single node with every primary active is UP with a note;
  yellow with an inactive primary or on multiple nodes stays DEGRADED,
  red stays DOWN.
- Unit tests (stub server), health/status tests and a Testcontainers
  integration test; OpenAPI fragment regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:43:13 -05:00
james.maes fd0322b8dc Merge pull request #65 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:16 -05:00
james.maes 3efff6de95 Merge pull request #66 from QRun-IO/fix/system-session-instance
fix: startup and diagnostics system sessions carry the instance
2026-10-16 01:29:33 -05:00
james.maesandClaude Opus 5.5 d4d5029691 fix: build startup and diagnostics system sessions with the instance
primeConfig created QSystemUserSession() before QContext.init, so
on a thread without a context (startup on [main]) the session had no
instance and no all-access keys, and kof22 logged "QInstance was not
supplied" once per pod. DiagnosticEventRecorder did the same.
Both now pass the instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 01:24:24 -05:00
james.maesandClaude Opus 5.5 3de57f534b ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:21:59 -05:00
james.maes 8ef66f6efe Merge pull request #63 from QRun-IO/feature/ui-api-1.6
feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.15)
2026-10-15 16:13:09 -05:00
james.maes 1705c73b06 Merge remote-tracking branch 'origin/develop' into feature/ui-api-1.6
# Conflicts:
#	CHANGELOG.md
#	pom.xml
2026-10-15 16:01:17 -05:00
james.maes ae5c00c589 Merge pull request #64 from QRun-IO/docs/rc11-release-note-correction
docs(changelog): release note correction for 1.1.0-RC.11
2026-10-15 14:34:50 -05:00
james.maesandClaude Opus 5.5 87768d3dd3 docs(changelog): release note correction for 1.1.0-RC.11
The v1.1.0-RC.11 tag points at 6028a66, but the RC.11 artifact on Maven
Central was built from 2e2501d (#61 only). Cluster caches (#62) first ship
in 1.1.0-RC.12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:34:35 -05:00
james.maes 832c628298 Merge pull request #62 from QRun-IO/feature/cluster-caches
Clear the Quick Search config cache on every node (ClusterCaches)
2026-10-15 14:11:18 -05:00
james.maesandClaude Opus 5.5 b4c7ba4697 build: QQQ 4.1.0-RC.14 (ships ClusterCaches)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:05:04 -05:00
james.maesandClaude Opus 5.5 6af1362668 chore: target QQQ 4.1.0-RC.15 (UI API 1.6.0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:27:06 -05:00
james.maesandClaude Opus 5.5 af9eefe311 feat(cluster): clear the Quick Search config cache on every node
Register the runtime config cache (config store snapshot and settings,
per-table enabled flags) with QQQ ClusterCaches as
qbit-quick-search.config. invalidateConfig() now clears locally and
publishes a cluster invalidation, so a config edit on one node reaches
every node at once; the existing TTLs stay as the safety net. The
warn-once table set in QuickSearchFieldRules is registered node-local.

Builds against QQQ 4.1.0-SNAPSHOT (qqq.snapshot.version) until the QQQ
RC with ClusterCaches is published; a release must pin that RC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:10:35 -05:00
james.maesandClaude Opus 5.5 a9550d82b4 feat(ui-api): extension for QQQ UI API 1.6.0 (operationIds, BasicErrorResponseV1, QQQ 4.1.0-RC.14)
Regenerated against UI API 1.6.0: every operation has an operationId and
the standard error responses (BasicErrorResponseV1: error, code, message,
fieldErrors, traceId); the extension's own error schema is kept but
deprecated; hand-written error bodies go through QJavalinUtils so they carry
code/message/traceId. Extension MINOR bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:48:12 -05:00
james.maes 3f7e67df4b Merge pull request #61 from QRun-IO/feature/quiet-index-bookkeeping-audits
fix(audit): do not record-audit indexing bookkeeping
2026-10-15 07:52:26 -05:00
james.maesandClaude Opus 5.5 a6887f7ea2 fix(audit): do not record-audit indexing bookkeeping
Basepull, full reindex and real-time indexing update runtime state on the
quick_search_index row (status, lastBasepullTime, documentCount, last run,
realTimeErrorCount), and every startup re-records the priming outcome on the
settings row. Each of those writes produced a FIELD audit ("Record was Edited
during process: Update search index"), about 2,800 audit rows in 6 hours on
kof22 production. These writes now set omitDmlAudit; admin edits to the
index, field and settings tables keep their audits. Tests prove both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:47:23 -05:00
james.maes bce0f7ff53 Merge pull request #60 from QRun-IO/fix/help-bundle-version
fix(help): help bundle version comes from the build, not a hardcoded SNAPSHOT
2026-10-14 22:07:01 -05:00
james.maesandClaude Opus 5.5 e9e3e59225 fix(help): the help bundle carries the artifact version (Maven-filtered) instead of a hardcoded SNAPSHOT
On release branches the bundle's version and the producer's differed, so the
bundle test that asserts they match failed, and released bundles recorded
SNAPSHOT as their provenance. help/*.yaml is now filtered:
version: ${project.version}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:00:05 -05:00
james.maes 1d1b8a4b7b Merge pull request #57 from QRun-IO/feature/health-checks
feat(health): register quick search checks with core central health
2026-10-14 21:27:00 -05:00
james.maesandClaude Opus 5.5 2fc2485fd8 Merge origin/develop into feature/health-checks; pin QQQ 4.1.0-RC.8
RC.8 ships the central health kernel (QRun-IO/qqq#1207).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:18:17 -05:00
james.maes e4d6d279f2 Merge pull request #59 from QRun-IO/feature/help-bundle
feat(help): qbit-quick-search help bundle via QQQ help bundles, with coverage gate
2026-10-14 20:45:30 -05:00
james.maesandClaude Opus 5.5 2cde433c35 docs: quick search health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:30 -05:00
james.maesandClaude Opus 5.5 6111d2fdf2 feat(health): register quick search checks with core central health
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:29 -05:00
james.maesandClaude Opus 5.5 99e7e0806a feat(health): deadline-bounded status reads for health checks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:28 -05:00
38 changed files with 2163 additions and 110 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.9
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.7.12
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.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
+62 -13
View File
@@ -1,25 +1,74 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
'on':
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types: [closed]
branches: [develop]
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
+16
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
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
+14 -1
View File
@@ -2,14 +2,24 @@
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
## Release note correction (1.1.0-RC.11)
1.1.0-RC.11 (Maven Central, 2026-10-15 13:05Z) was built from release/1.1 `2e2501d` and contains #61 only. The `v1.1.0-RC.11` tag mistakenly points at `6028a66`; use `2e2501d` as RC.11's source. Cluster caches (#62) first ship in 1.1.0-RC.12.
## [Unreleased]
Requires QQQ 4.1.0-RC.7 or later (help bundles and the spec-managed UI API extension contract).
Requires QQQ 4.1.0-RC.15 or later (central health checks, help bundles, the spec-managed UI API extension contract, and `ClusterCaches` for multi-node deployments).
### Fixed
- Single-node clusters: the Quick Search index no longer asks for a replica it cannot place, so a one-node OpenSearch stays green instead of yellow and `quick_search.opensearch` is no longer DEGRADED. New `indexReplicas` (default null: automatic, `min(maxAutoReplicas, data nodes - 1)`) and `maxAutoReplicas` (default 1) config; zero config gives 0 replicas on one node and 1 on larger clusters. The target is set on every new physical index and reconciled on existing ones at startup with `PUT /<index>/_settings` when it differs (idempotent, logged; a failure is logged and reported, never blocks startup; indexes with `auto_expand_replicas` and OpenSearch Serverless are left alone). The status snapshot and the `quick_search.opensearch` health details gain `unassignedReplicaShards`, `targetReplicas` and `replicaSettingsFailed`; yellow on a single node with every primary active (only replicas unassigned) is reported UP with a note. Yellow with an unassigned primary, or on a multi-node cluster, stays DEGRADED; red stays DOWN.
- Priming config records at startup, and recording diagnostic events, build their system session with the instance, so it has its all-access keys. Startup logged "QInstance was not supplied when creating a QSystemUserSession" once per node.
- Multi-node deployments: a Quick Search config change (an index row, its fields or the settings row, from the admin UI or a config process) now clears the cached config on every node, not only the node that made the change. The runtime's config cache (the config store snapshot and settings, and the per-table enabled flags) is registered with QQQ `ClusterCaches` as `qbit-quick-search.config`, and `QuickSearchRuntime.invalidateConfig()` publishes a cluster invalidation. The 30 s and 60 s TTLs remain as the safety net. `QuickSearchFieldRules`' warn-once table set is registered as node-local. `QuickSearchClusterCachesTest` audits the package for unregistered static caches and covers a remote invalidation.
- Indexing bookkeeping is no longer record-audited: the `quick_search_index` runtime fields written by basepull, full reindex and real-time indexing (status, last basepull time, document count, last run, real-time error count) and the settings row's priming outcome (`lastPrimedAt`, summary) are written with `omitDmlAudit`. On a production host these wrote about 470 "Record was Edited during process: Update search index" audit rows an hour. Admin edits to an index, its fields or the settings are still audited.
- Real-time indexing failures now increase the table's error count by the number of failed events. Successful basepulls and full reindexes reset the count. Counter update failures are logged without failing the source write.
### Added
- UI API extension quick-search 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and retained deprecated QuickSearchErrorV1 schema. Existing operationIds and success payloads are preserved.
- Central health checks `quick_search.opensearch`, `quick_search.backlog` and the dynamic `quick_search.index.<indexName>` supplier, all SEARCH/DEPENDENCY checks every 60 seconds. Reuses cached diagnostics with deadline-bounded waits and safe count/flag details. Includes registration, state, deadline and credential-sentinel tests.
- Host-neutral `help/qbit-quick-search.yaml` bundle, registered through QQQ help bundles, with coverage and no-help-table tests. Replaces the temporary inline help loader; hosts can override entries with a HOST bundle.
- Every `quick_search_*` table and the Quick Search Admin app carry help that says what the table is for and how its rows get created (`help/quick-search.yaml`, QQQ help-bundle format, applied by `QuickSearchHelp` as a stand-in for core help bundles; host help is never overridden). `QuickSearchTablesGuardrailTest` fails when a table is unused by runtime code, neither primed nor listed as starting empty, or has no such help; `QuickSearchSeededDefaultsTest` proves priming populates the config tables on a fresh backend, is idempotent and keeps admin edits.
- Session-only typeahead: `GET /qqq/v1/qbit/quick-search/suggest` (`getQuickSearchSuggestions`) and `QuickSearchSuggestService.suggest(...)`. Reuses the permission-aware search action with one bounded candidate batch per table; groups expose only record IDs, filtered labels/subtitles, `hasMore` and `truncated`. Defaults to 5 items across 8 tables (maximum 10/20); short terms and unavailable search return empty groups. The existing UI extension and generated contract now carry both status and suggestions.
@@ -22,6 +32,9 @@ Requires QQQ 4.1.0-RC.7 or later (help bundles and the spec-managed UI API exten
- `QuickSearchResult.subtitle` and `displayValues` (from `SUBTITLE` and `DISPLAY` fields); document objects `fieldValuesWord`, `fieldValuesEnglish`, `fieldValuesExact`, added to existing indexes' mapping at startup.
- Possible-value sources `quick_search_eligible_table`, `quick_search_indexable_field`, `quick_search_timestamp_field`, `quick_search_field_role`, `quick_search_field_analyzer`, `quick_search_documents_choice`, `quick_search_config_source`; the join `quick_search_index_join_quick_search_index_field` and the Searchable Fields widget on the index record. Config tables are audited at field level.
### Removed
- None for central health integration; the status API, diagnostics widget and developer panel are retained.
### Changed
- Distinguish protected fields from fields that simply cannot be searched. Boolean, blob and hidden fields no longer trigger result rebuilding or erase record labels. Labels are suppressed only when their source fields are protected; unprotected hidden primary keys and timestamps remain usable.
- Exclude PII/PHI-classified fields (including table defaults) and field-security-locked fields everywhere Quick Search indexes or displays values. Configuration rejects protected fields; code defaults drop them with one warning per table. Search and diagnostics omit protected values from older documents. Rebuild existing indexes after upgrading or changing classification metadata. Uses QQQ 4.1.0-RC.6; no dependency changes.
+29 -2
View File
@@ -227,6 +227,8 @@ The panel's status is Indexed, Stale, Not indexed, Disabled (the table is turned
| `runHistoryRetentionDays` | 30 | Purge run records older than this; null keeps them |
| `fullReindexStaleMinutes` | 30 | A running full reindex that shows no sign of life for this long is treated as stopped: it no longer blocks a new one, and basepull recovers its tables. Keep it well above the time one page of `sourceBatchSize` records takes; null means the default |
| `bulkBatchSize`, `maxBulkRequestBytes`, `sourceBatchSize` | 500, 5 MiB, 1000 | Batch sizes |
| `indexReplicas` | null (automatic) | Index `number_of_replicas`; null means `min(maxAutoReplicas, data nodes - 1)`, so a single node gets 0 and stays green. Set on new indexes, reconciled on existing ones at startup |
| `maxAutoReplicas` | 1 | Most replicas automatic sizing asks for |
| `maxFieldLength` | 10000 | Truncate each indexed value |
| `maxSearchLimit` | 100 | Largest page size |
| `applyRecordSecurityLocks` | `true` | Re-read hits through `QueryAction` |
@@ -275,7 +277,7 @@ CI=true ./mvnw verify # plus Testcontainers integration te
./mvnw verify -Dopensearch.test.image=opensearchproject/opensearch:3.9.0
```
Use the wrapper: Maven 3.10 cannot read the published parent POM. The build imports QQQ 4.1.0-RC.7 through the default-active `qqq-snapshot` profile ahead of the parent BOM (override only with a compatible released version using `-Dqqq.snapshot.version=...`). Without Docker the integration tests are skipped locally; with `CI=true` they fail instead.
Use the wrapper: Maven 3.10 cannot read the published parent POM. The build imports QQQ 4.1.0-RC.8 through the default-active `qqq-snapshot` profile ahead of the parent BOM (override only with a compatible released version using `-Dqqq.snapshot.version=...`). Without Docker the integration tests are skipped locally; with `CI=true` they fail instead.
## Upgrading from 1.0
@@ -358,7 +360,7 @@ String homeHref = status.homeHref();
```
UI clients use **`GET /qqq/v1/qbit/quick-search/status`**, operationId
`getQuickSearchStatus`, extension `quick-search` version `1.0.0`. The endpoint returns
`getQuickSearchStatus`, extension `quick-search` version `1.1.0` (requires QQQ `4.1.0-RC.15`, UI API `1.6.0`). The endpoint returns
that same immutable value as JSON; it requires the Quick Search app's configured
`adminPermissionRules` (default `quick_search_admin.hasAccess`). Permission is checked
before every read, including cache hits. No additional source-table permissions are
@@ -399,6 +401,31 @@ configured metadata prefix. `QuickSearchApi.APP_NAME` (`quickSearchAdmin`) and
navigation. Keep detailed runs, failed events, validation and operator tools in the
qbit's own home. [kof22 migration mapping](docs/STATUS-API-HOST-MIGRATION.md).
## Health checks
The QBit producer registers these checks with QQQ's central health registry. All use
component `quick_search`, category `SEARCH`, a 60-second interval and a 5-second timeout.
| Name | Kind | Interval | Reports |
| --- | --- | --- | --- |
| `quick_search.opensearch` | DEPENDENCY | 60s | Cluster reachability and colour: red is DOWN, yellow is DEGRADED, disabled is UNKNOWN. |
| `quick_search.backlog` | DEPENDENCY | 60s | DEGRADED for any exhausted events, more than 100 pending events, or paused indexing. |
| `quick_search.index.<indexName>` | DEPENDENCY | 60s | One check per persisted index row; non-active status, count mismatch, last error or real-time errors are DEGRADED. Disabled indexes are UNKNOWN. |
Index names come from the row's table name, normalized to lowercase health identifiers.
The supplier refreshes the set through core's normal discovery cycle. Checks reuse the
five-second status cache and saved validation counts, with waits bounded by the runner's
deadline. Incomplete measurements never become zero; indexes absent from the bounded
100-row status snapshot report UNKNOWN. Backlog truncation reports DEGRADED.
Details contain counts, timestamps and flags, never stored error text or credentials.
The status endpoint, diagnostics widget and record developer panel remain available.
This integration requires QQQ 4.1.0-RC.8 or later, which includes the central health SPI. The POM pins that release. For local offline verification:
```powershell
./mvnw.cmd -o verify
```
## Classified data
Quick Search excludes fields classified as **PII** or **PHI**, including fields
+1 -1
View File
@@ -34,7 +34,7 @@ group has `tableName`, `tableLabel`, `hasMore` and `items` of `recordId`, `label
and nullable `subtitle`. See the [API details](../README.md#typeahead-suggestions)
for normalization, bounded-batch semantics and empty responses during outages.
Extension: `quick-search`, version `1.0.0`; core dependency: `4.1.0-RC.6`.
Extension: `quick-search`, version `1.1.0`; core dependency: `4.1.0-RC.15` (UI API `1.6.0`).
The requested `/quick-search` prefix cannot validate on the supplied RC.6 baseline:
`MiddlewareSpecExtensionsMetaData.ESTABLISHED_PREFIXES` only admits esb, alerts,
assistant, and sql-workbench. We use the default namespace without modifying core.
+9
View File
@@ -1,5 +1,14 @@
# TODO
## UI API 1.6 migration (2026-10-15)
- [x] Bump `quick-search` extension to 1.1.0, retain deprecated `QuickSearchErrorV1`, and use core `BasicErrorResponseV1` for errors; preserve operation IDs and success payloads.
- [x] Regenerate the two-operation contract with `-Dquicksearch.openapi.update=true`, then pass the contract test without regeneration; no dangling references or separate baseline were found.
- [x] Set the release BOM default to QQQ `4.1.0-RC.15`; validate offline against the locally installed `4.1.99-SNAPSHOT` using `./mvnw.cmd -o test -Dqqq.snapshot.version=4.1.99-SNAPSHOT -Denforcer.skipRules=requireReleaseDeps` (540 tests, no failures/errors/skips). The command-line exception is needed only because the requested local core is a SNAPSHOT; other guardrails remain enabled.
- [x] Verify strengthened HTTP error-body assertions: 8 status/HTTP tests passed after the full suite.
- [ ] Run Docker integration tests through `verify` in CI; this migration ran the requested `test` lifecycle.
## In-app help bundle (2026-10-14)
- [x] Pin QQQ RC.7, register the host-neutral QBIT bundle, replace the temporary loader, document HOST overrides, and add coverage/no-help-table checks.
+3 -1
View File
@@ -170,6 +170,7 @@
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>help/*.yaml</include>
<include>quick-search.properties</include>
</includes>
</resource>
@@ -177,6 +178,7 @@
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>help/*.yaml</exclude>
<exclude>quick-search.properties</exclude>
</excludes>
</resource>
@@ -281,7 +283,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.7</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -81,6 +81,7 @@ public class QuickSearchQBitConfig implements QBitConfig
public static final int DEFAULT_VALIDATION_SAMPLE_SIZE = 50;
public static final int MAX_VALIDATION_SAMPLE_SIZE = 1000;
public static final int DEFAULT_DIAGNOSTIC_RUNS_PER_MINUTE = 30;
public static final int DEFAULT_MAX_AUTO_REPLICAS = 1;
///////////////////////////////////////////////////////////////////////////////
// a live full reindex refreshes its run record after every page; a page is //
@@ -113,6 +114,8 @@ public class QuickSearchQBitConfig implements QBitConfig
private Integer responseTimeoutMillis = 60000;
private Integer maxConnections = 30;
private Integer maxBulkRequestBytes = 5 * 1024 * 1024;
private Integer indexReplicas;
private Integer maxAutoReplicas = DEFAULT_MAX_AUTO_REPLICAS;
private QCodeReference transportCustomizer;
private QuickSearchStartupMode startupMode = QuickSearchStartupMode.FAIL_FAST;
private Boolean enableRealTimeIndexing = true;
@@ -443,6 +446,16 @@ public class QuickSearchQBitConfig implements QBitConfig
{
errors.add("basepullOverlapSeconds must not be negative for QuickSearchQBit");
}
if(indexReplicas != null && indexReplicas < 0)
{
errors.add("indexReplicas must not be negative for QuickSearchQBit");
}
if(maxAutoReplicas != null && maxAutoReplicas < 0)
{
errors.add("maxAutoReplicas must not be negative for QuickSearchQBit");
}
}
@@ -1495,6 +1508,73 @@ public class QuickSearchQBitConfig implements QBitConfig
/*******************************************************************************
** Getter for indexReplicas: number_of_replicas for the Quick Search index.
** Null (the default) means automatic: min(maxAutoReplicas, data nodes - 1),
** so a single-node cluster gets 0 replicas and stays green. Applied when a
** physical index is created and reconciled on existing indexes at startup.
** Ignored for OpenSearch Serverless (awsServiceName aoss).
*******************************************************************************/
public Integer getIndexReplicas()
{
return (this.indexReplicas);
}
/*******************************************************************************
** Setter for indexReplicas
*******************************************************************************/
public void setIndexReplicas(Integer indexReplicas)
{
this.indexReplicas = indexReplicas;
}
/*******************************************************************************
** Fluent setter for indexReplicas
*******************************************************************************/
public QuickSearchQBitConfig withIndexReplicas(Integer indexReplicas)
{
this.indexReplicas = indexReplicas;
return (this);
}
/*******************************************************************************
** Getter for maxAutoReplicas: the most replicas automatic mode (indexReplicas
** null) asks for; it never asks for more than data nodes - 1. Default 1.
*******************************************************************************/
public Integer getMaxAutoReplicas()
{
return (this.maxAutoReplicas);
}
/*******************************************************************************
** Setter for maxAutoReplicas
*******************************************************************************/
public void setMaxAutoReplicas(Integer maxAutoReplicas)
{
this.maxAutoReplicas = maxAutoReplicas;
}
/*******************************************************************************
** Fluent setter for maxAutoReplicas
*******************************************************************************/
public QuickSearchQBitConfig withMaxAutoReplicas(Integer maxAutoReplicas)
{
this.maxAutoReplicas = maxAutoReplicas;
return (this);
}
/*******************************************************************************
** Getter for transportCustomizer: QCodeReference to an OpenSearchTransportCustomizer
*******************************************************************************/
@@ -30,6 +30,7 @@ import com.kingsrook.qbits.quicksearch.annotations.QuickSearchField;
import com.kingsrook.qbits.quicksearch.annotations.QuickSearchable;
import com.kingsrook.qbits.quicksearch.api.QuickSearchApi;
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
import com.kingsrook.qbits.quicksearch.health.QuickSearchHealthChecks;
import com.kingsrook.qbits.quicksearch.listeners.QuickSearchRecordChangeListener;
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchAdminAppMetaDataProducer;
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchDiagnosticsProcessMetaDataHelper;
@@ -263,6 +264,7 @@ public class QuickSearchQBitProducer implements QBitMetaDataProducer<QuickSearch
QuickSearchRuntime runtime = new QuickSearchRuntime(qBitConfig);
runtime.setDiscoveredTables(discoveredTables);
qBitConfig.setRuntime(runtime);
QuickSearchHealthChecks.register(qInstance);
if(Boolean.TRUE.equals(qBitConfig.getEnableRealTimeIndexing()))
{
@@ -23,6 +23,8 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
@@ -34,6 +36,7 @@ import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient;
import com.kingsrook.qbits.quicksearch.publisher.IndexEventPublisher;
import com.kingsrook.qbits.quicksearch.publisher.SynchronousIndexEventPublisher;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -70,6 +73,17 @@ public class QuickSearchRuntime
private static final long ENABLED_CACHE_TTL_MILLIS = 60_000L;
/*******************************************************************************
** Cluster cache name for every runtime's config cache (the config store's
** snapshot and settings, and the enabled flags). A config row edited on
** one node clears it on every node; the TTLs remain the safety net.
*******************************************************************************/
public static final String CONFIG_CACHE_NAME = "qbit-quick-search.config";
private static final Set<QuickSearchRuntime> RUNTIMES = ClusterCaches.shared(CONFIG_CACHE_NAME,
Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>())),
QuickSearchRuntime::invalidateAllLocal, null);
public static final long RECORD_SEARCH_BACKOFF_MILLIS = 30_000L;
private final StatusCache statusCache = new StatusCache();
@@ -128,6 +142,7 @@ public class QuickSearchRuntime
{
this.config = config;
this.configStore = new QuickSearchConfigStore(this);
RUNTIMES.add(this);
}
@@ -261,9 +276,21 @@ public class QuickSearchRuntime
/*******************************************************************************
** Drop every cached config (a config row changed).
** Drop every cached config (a config row changed): on this node now, and
** on the other nodes through the cluster bus.
*******************************************************************************/
public void invalidateConfig()
{
invalidateConfigLocal();
ClusterCaches.invalidate(CONFIG_CACHE_NAME);
}
/*******************************************************************************
** Drop this runtime's cached config on this node only.
*******************************************************************************/
void invalidateConfigLocal()
{
configStore.invalidate();
enabledCache.clear();
@@ -271,6 +298,25 @@ public class QuickSearchRuntime
/*******************************************************************************
** Drop the cached config of every live runtime on this node (a cluster
** invalidation arrived).
*******************************************************************************/
private static void invalidateAllLocal()
{
List<QuickSearchRuntime> runtimes;
synchronized(RUNTIMES)
{
runtimes = new ArrayList<>(RUNTIMES);
}
for(QuickSearchRuntime runtime : runtimes)
{
runtime.invalidateConfigLocal();
}
}
/*******************************************************************************
** Return the OpenSearch client, building it on first use. Building the
** client does not contact the cluster; {@link #ensureIndexReady()} does.
@@ -422,6 +468,16 @@ public class QuickSearchRuntime
/*******************************************************************************
** How many enabled flags are cached (for tests).
*******************************************************************************/
int getCachedEnabledFlagCount()
{
return (enabledCache.size());
}
/*******************************************************************************
** Forget cached enabled flags (after an operator toggles a table).
*******************************************************************************/
@@ -702,7 +758,7 @@ public class QuickSearchRuntime
{
return;
}
QContext.init(qInstance, new QSystemUserSession());
QContext.init(qInstance, new QSystemUserSession(qInstance));
try
{
configStore.tryPrime();
@@ -741,6 +797,7 @@ public class QuickSearchRuntime
return;
}
closed = true;
RUNTIMES.remove(this);
statusCache.close();
if(publisher != null)
@@ -48,7 +48,7 @@ public final class QuickSearchApi
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("quick-search").withSemanticVersion("1.0.0")
return (new MiddlewareSpecExtension().withName("quick-search").withSemanticVersion("1.1.0")
.withDescription("Quick Search administration and suggestions").withSpec(QuickSearchStatusSpec.class).withSpec(QuickSearchSuggestSpec.class));
}
}
@@ -30,11 +30,41 @@ import com.kingsrook.qqq.openapi.model.Schema;
/*******************************************************************************
** Immutable administrative snapshot. Null measurements are unknown, never zero.
** Counts come from persisted diagnostics; countsCheckedAt identifies their age.
**
** unassignedReplicaShards is the unassigned shard count when every primary is
** active (so all unassigned shards are replicas), else null; targetReplicas and
** replicaSettingsFailed report the index replica sizing (null when unknown).
*******************************************************************************/
public record QuickSearchStatus(State state, Boolean clusterReachable, String clusterHealth, Integer nodes, Integer activeShards, Integer unassignedShards,
Integer unassignedReplicaShards, Integer targetReplicas, Boolean replicaSettingsFailed,
Boolean indexingPaused, List<IndexRow> indexes, Integer pending, Integer exhausted, Boolean backlogTruncated,
Boolean tablesTruncated, Instant lastSuccessfulSync, String lastError, Instant checkedAt, String homeHref) implements ToSchema
{
/*******************************************************************************
** The 1.1.0-RC.14 shape, without the replica measurements (all unknown).
*******************************************************************************/
public QuickSearchStatus(State state, Boolean clusterReachable, String clusterHealth, Integer nodes, Integer activeShards, Integer unassignedShards,
Boolean indexingPaused, List<IndexRow> indexes, Integer pending, Integer exhausted, Boolean backlogTruncated,
Boolean tablesTruncated, Instant lastSuccessfulSync, String lastError, Instant checkedAt, String homeHref)
{
this(state, clusterReachable, clusterHealth, nodes, activeShards, unassignedShards, null, null, null, indexingPaused, indexes, pending, exhausted,
backlogTruncated, tablesTruncated, lastSuccessfulSync, lastError, checkedAt, homeHref);
}
/*******************************************************************************
** Yellow only because replicas cannot be placed on a single-node cluster:
** one node, every primary active and every unassigned shard a replica.
** Such a cluster cannot do better, so it is healthy rather than degraded.
*******************************************************************************/
public static boolean isSingleNodeReplicaYellow(String clusterHealth, Integer nodes, Integer unassignedReplicaShards)
{
return ("yellow".equals(clusterHealth) && Integer.valueOf(1).equals(nodes) && unassignedReplicaShards != null && unassignedReplicaShards > 0);
}
/*******************************************************************************
** Published wire schema for the same immutable value used by Java hosts.
*******************************************************************************/
@@ -27,6 +27,7 @@ import java.util.TreeMap;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Method;
import com.kingsrook.qqq.openapi.model.Parameter;
@@ -103,9 +104,9 @@ public class QuickSearchStatusSpec extends AbstractJsonEndpointSpec
@Override
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Diagnostic rate limit exceeded", "QuickSearchErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "QuickSearchErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Quick Search admin permission required", "QuickSearchErrorV1")));
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Diagnostic rate limit exceeded", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "Quick Search admin permission required", BasicErrorResponseV1.class.getSimpleName())));
}
@@ -125,6 +126,9 @@ public class QuickSearchStatusSpec extends AbstractJsonEndpointSpec
{
status.put(name, field(Type.INTEGER, "Cluster health counter; null when unavailable.", true));
}
status.put("unassignedReplicaShards", field(Type.INTEGER, "Unassigned shards when every primary is active (all are replicas); null when unknown or a primary is not active.", true));
status.put("targetReplicas", field(Type.INTEGER, "number_of_replicas Quick Search applies to its index; null when unknown or not managed (Serverless).", true));
status.put("replicaSettingsFailed", field(Type.BOOLEAN, "Sizing or applying the index replicas failed at startup; null when not attempted.", true));
status.put("indexingPaused", field(Type.BOOLEAN, "Indexing is paused; search can still serve.", true));
status.put("indexes", new Schema().withType(Type.ARRAY).withItems(new Schema().withRef("#/components/schemas/QuickSearchIndexStatusV1"))
.withDescription("At most 100 persisted index rows, ordered by id."));
@@ -147,7 +151,8 @@ public class QuickSearchStatusSpec extends AbstractJsonEndpointSpec
row.put("lastError", field(Type.STRING, "Persisted indexing error; null when absent.", true));
row.put("realTimeErrorCount", field(Type.INTEGER, "Persisted real-time indexing error count.", true));
return (Map.of("QuickSearchStatusV1", object(status), "QuickSearchIndexStatusV1", object(row),
"QuickSearchErrorV1", object(Map.of("error", field(Type.STRING, "Framework error message.", false)))));
"QuickSearchErrorV1", object(Map.of("error", field(Type.STRING, "Framework error message.", false)))
.withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.")));
}
@@ -27,6 +27,7 @@ import java.util.TreeMap;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.In;
import com.kingsrook.qqq.openapi.model.Method;
@@ -115,9 +116,9 @@ public class QuickSearchSuggestSpec extends AbstractJsonEndpointSpec
@Override
public List<BasicResponse> defineAdditionalBasicResponses()
{
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Missing q, normalized term longer than 100 characters, or invalid limits", "QuickSearchErrorV1"),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "QuickSearchErrorV1"),
new BasicResponse(HttpStatus.FORBIDDEN, "Session permission denied", "QuickSearchErrorV1")));
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Missing q, normalized term longer than 100 characters, or invalid limits", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
new BasicResponse(HttpStatus.FORBIDDEN, "Session permission denied", BasicErrorResponseV1.class.getSimpleName())));
}
@@ -412,8 +412,12 @@ public class QuickSearchConfigPrimer
}
else
{
///////////////////////////////////////////////////////////////////////
// Only the priming outcome (lastPrimedAt, summary) changes here, on //
// every startup: bookkeeping, not a settings change, so not audited. //
///////////////////////////////////////////////////////////////////////
values.withValue("id", rows.get(0).getValue("id"));
updateAll(settingsTable, List.of(values));
updateAll(settingsTable, List.of(values), true);
}
}
@@ -477,6 +481,17 @@ public class QuickSearchConfigPrimer
** Update rows, failing on any record error.
*******************************************************************************/
public static void updateAll(String tableName, List<QRecord> records) throws QException
{
updateAll(tableName, records, false);
}
/*******************************************************************************
** Update rows, failing on any record error; omitAudit skips the DML audit
** for runtime bookkeeping writes.
*******************************************************************************/
public static void updateAll(String tableName, List<QRecord> records, boolean omitAudit) throws QException
{
if(records.isEmpty())
{
@@ -485,6 +500,7 @@ public class QuickSearchConfigPrimer
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(records);
updateInput.setOmitDmlAudit(omitAudit);
for(QRecord record : CollectionUtils.nonNullList(new UpdateAction().execute(updateInput).getRecords()))
{
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
@@ -60,8 +60,9 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** before 1.1.0-RC.3.
**
** Reads are cached for CACHE_TTL_MILLIS and dropped at once by the config
** tables' customizers when a row changes on this server; other servers pick
** the change up when their cache expires.
** tables' customizers when a row changes, on every server: the runtime
** publishes a cluster cache invalidation (QuickSearchRuntime.CONFIG_CACHE_NAME).
** The TTL remains the safety net when the cluster bus is down.
*******************************************************************************/
public class QuickSearchConfigStore
{
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.actions.ai.AiDataClassifications;
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
import com.kingsrook.qqq.backend.core.cluster.ClusterCaches;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
@@ -59,7 +60,8 @@ import com.kingsrook.qbits.quicksearch.model.QuickSearchFieldRole;
public final class QuickSearchFieldRules
{
private static final QLogger LOG = QLogger.getLogger(QuickSearchFieldRules.class);
private static final WeakHashMap<QTableMetaData, Boolean> WARNED_TABLES = new WeakHashMap<>();
private static final WeakHashMap<QTableMetaData, Boolean> WARNED_TABLES = ClusterCaches.nodeLocal(new WeakHashMap<>(),
"warn-once log de-duplication for this node's table metadata objects");
public static final int MAX_DEFAULT_FIELDS = 20;
public static final int TITLE_WEIGHT = 3;
@@ -124,7 +124,7 @@ public class DiagnosticEventRecorder implements AutoCloseable
*******************************************************************************/
private void insert(QInstance qInstance, String tableName, QuickSearchDiagnosticEvent event)
{
QSession session = new QSystemUserSession();
QSession session = new QSystemUserSession(qInstance);
try
{
QContext.init(qInstance, session);
@@ -21,12 +21,15 @@
package com.kingsrook.qbits.quicksearch.diagnostics;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.locks.ReentrantLock;
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
import com.kingsrook.qbits.quicksearch.api.QuickSearchApi;
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatus;
@@ -42,6 +45,7 @@ import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
*******************************************************************************/
public class StatusCache implements AutoCloseable
{
private final ReentrantLock lock = new ReentrantLock();
private final ExecutorService executor = Executors.newSingleThreadExecutor(runnable ->
{
Thread thread = new Thread(runnable, "quick-search-status");
@@ -58,7 +62,47 @@ public class StatusCache implements AutoCloseable
** Authorization belongs to the public service, before reaching this cache.
** Only refreshes consume the existing per-user diagnostic rate limit.
*******************************************************************************/
public synchronized QuickSearchStatus get(QuickSearchRuntime runtime) throws QException
public QuickSearchStatus get(QuickSearchRuntime runtime) throws QException
{
return (get(runtime, Instant.now().plusSeconds(2)));
}
/*******************************************************************************
** Bound both lock contention and the shared refresh wait by the caller's deadline.
*******************************************************************************/
public QuickSearchStatus get(QuickSearchRuntime runtime, Instant deadline) throws QException
{
try
{
long remaining = Duration.between(Instant.now(), deadline).toNanos();
if(remaining <= 0 || !lock.tryLock(remaining, TimeUnit.NANOSECONDS))
{
throw (new QException("Status deadline exceeded"));
}
try
{
return (read(runtime, deadline));
}
finally
{
lock.unlock();
}
}
catch(InterruptedException e)
{
Thread.currentThread().interrupt();
throw (new QException("Status refresh interrupted", e));
}
}
/*******************************************************************************
** Read or refresh while holding the deadline-aware cache lock.
*******************************************************************************/
private QuickSearchStatus read(QuickSearchRuntime runtime, Instant deadline) throws QException
{
if(cached != null && System.nanoTime() < expiresAt)
{
@@ -90,7 +134,8 @@ public class StatusCache implements AutoCloseable
}
try
{
cached = refresh.get(2, TimeUnit.SECONDS);
long remaining = Math.max(0, Duration.between(Instant.now(), deadline).toNanos());
cached = refresh.get(Math.min(TimeUnit.SECONDS.toNanos(2), remaining), TimeUnit.NANOSECONDS);
refresh = null;
}
catch(TimeoutException e)
@@ -135,6 +135,9 @@ public final class StatusDiagnostics
Integer nodes = null;
Integer active = null;
Integer unassigned = null;
Integer unassignedReplicas = null;
Integer targetReplicas = null;
Boolean replicaSettingsFailed = null;
State state = degraded ? State.DEGRADED : State.OK;
if(!truncated && !indexes.isEmpty() && indexes.stream().noneMatch(row -> Boolean.TRUE.equals(row.enabled())))
{
@@ -152,7 +155,15 @@ public final class StatusDiagnostics
nodes = number(body, "number_of_nodes");
active = number(body, "active_shards");
unassigned = number(body, "unassigned_shards");
if(IndexValidator.healthLevel(health) != DiagnosticsHtml.Level.OK || body.path("timed_out").asBoolean())
unassignedReplicas = unassignedReplicaShards(body, unassigned);
var replicaStatus = runtime.getClient().getReplicaStatus();
if(replicaStatus != null)
{
targetReplicas = replicaStatus.targetReplicas();
replicaSettingsFailed = replicaStatus.failed();
}
boolean singleNodeReplicas = QuickSearchStatus.isSingleNodeReplicaYellow(health, nodes, unassignedReplicas);
if((IndexValidator.healthLevel(health) != DiagnosticsHtml.Level.OK && !singleNodeReplicas) || body.path("timed_out").asBoolean())
{
state = "red".equals(health) ? State.DOWN : State.DEGRADED;
}
@@ -167,7 +178,8 @@ public final class StatusDiagnostics
lastError = "OpenSearch health is unavailable; see Quick Search diagnostics.";
}
}
return (new QuickSearchStatus(state, reachable, health, nodes, active, unassigned, paused, indexes, pending, exhausted, backlog.size() > MAX_BACKLOG,
return (new QuickSearchStatus(state, reachable, health, nodes, active, unassigned, unassignedReplicas, targetReplicas, replicaSettingsFailed,
paused, indexes, pending, exhausted, backlog.size() > MAX_BACKLOG,
truncated, lastSync, lastError, Instant.now(), homeHref));
}
@@ -193,6 +205,30 @@ public final class StatusDiagnostics
/*******************************************************************************
** From index-level cluster health: the unassigned shards when nothing is
** initializing and every index has all its primaries active (so every
** unassigned shard is a replica); null otherwise or when unknown.
*******************************************************************************/
static Integer unassignedReplicaShards(JsonNode body, Integer unassigned)
{
if(unassigned == null || !body.path("indices").isObject() || !Integer.valueOf(0).equals(number(body, "initializing_shards")))
{
return (null);
}
for(JsonNode index : body.path("indices"))
{
Integer primaries = number(index, "number_of_shards");
if(primaries == null || !primaries.equals(number(index, "active_primary_shards")))
{
return (null);
}
}
return (unassigned);
}
/*******************************************************************************
** Preserve unknown counters from incomplete cluster responses.
*******************************************************************************/
@@ -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.quicksearch.health;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatus;
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatus.IndexRow;
import com.kingsrook.qbits.quicksearch.config.QuickSearchConfigPrimer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheck;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplier;
import com.kingsrook.qqq.backend.core.health.HealthCheckSupplierMetaData;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthDetails;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
/*******************************************************************************
** Central health projections of the existing, shared diagnostic snapshot.
** Free-form errors and remote strings never cross this boundary.
*******************************************************************************/
public class QuickSearchHealthChecks implements HealthCheckSupplier
{
/*******************************************************************************
** Register once, even if the producer is run again on the same instance.
*******************************************************************************/
public static void register(QInstance instance)
{
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
if(registry.getChecks().stream().noneMatch(check -> "quick_search.opensearch".equals(check.getName())))
{
registry.withCheck(definition("quick_search.opensearch", input -> run(input, QuickSearchHealthChecks::opensearch)));
}
if(registry.getChecks().stream().noneMatch(check -> "quick_search.backlog".equals(check.getName())))
{
registry.withCheck(definition("quick_search.backlog", input -> run(input, QuickSearchHealthChecks::backlog)));
}
if(registry.getSuppliers().stream().noneMatch(supplier -> "quick_search.index".equals(supplier.getNamePrefix())))
{
registry.withSupplier(new HealthCheckSupplierMetaData().withNamePrefix("quick_search.index").withComponent("quick_search")
.withSupplier(new QCodeReference(QuickSearchHealthChecks.class)));
}
}
/*******************************************************************************
** Enumerate persisted index identities, including disabled indexes. Execution
** reads a fresh cached snapshot, never a row captured at supplier resolution.
*******************************************************************************/
@Override
public List<HealthCheckMetaData> supply(QInstance instance) throws QException
{
QuickSearchRuntime runtime = runtime(instance);
if(runtime == null)
{
return (List.of());
}
List<HealthCheckMetaData> checks = new ArrayList<>();
for(QRecord row : QuickSearchConfigPrimer.queryAll(runtime.getConfig().getQuickSearchIndexTableName()))
{
String tableName = row.getValueString("tableName");
String name = tableName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9._-]+", "_").replaceAll("[._-]{2,}", "_").replaceAll("^[._-]|[._-]$", "");
if(name.isEmpty())
{
throw (new QException("Index name cannot form a health check identifier"));
}
checks.add(definition("quick_search.index." + name, input -> run(input, status -> index(status, tableName))));
}
return (checks);
}
/*******************************************************************************
** All Quick Search checks are dependencies; none gate Kubernetes readiness.
*******************************************************************************/
private static HealthCheckMetaData definition(String name, HealthCheck check)
{
return (new HealthCheckMetaData().withName(name).withLabel(name).withComponent("quick_search")
.withCategory(HealthCategory.SEARCH).withKind(HealthCheckKind.DEPENDENCY)
.withIntervalSeconds(60).withTimeoutMs(5000).withCheckInstance(check));
}
/*******************************************************************************
** Resolve from the supplied instance, not a static or previously closed runtime.
*******************************************************************************/
private static QuickSearchRuntime runtime(QInstance instance)
{
if(instance != null && instance.getQBits() != null)
{
for(QBitMetaData qbit : instance.getQBits().values())
{
if(qbit.getConfig() instanceof QuickSearchQBitConfig config)
{
return (config.getRuntime());
}
}
}
return (null);
}
/*******************************************************************************
** Share the diagnostic worker and respect the deadline even on a cache hit.
** Expected diagnostic failures use fixed messages, never exception payloads.
*******************************************************************************/
private static HealthCheckOutcome run(HealthCheckInput input, Function<QuickSearchStatus, HealthCheckOutcome> project)
{
if(!Instant.now().isBefore(input.getDeadline()))
{
return (HealthCheckOutcome.down("Quick Search health deadline exceeded"));
}
QuickSearchRuntime runtime = runtime(input.getQInstance());
if(runtime == null)
{
return (HealthCheckOutcome.unknown("Quick Search is not configured"));
}
try
{
QuickSearchStatus status = runtime.getStatusCache().get(runtime, input.getDeadline());
if(!Instant.now().isBefore(input.getDeadline()))
{
return (HealthCheckOutcome.down("Quick Search health deadline exceeded"));
}
return (project.apply(status));
}
catch(QException e)
{
return (HealthCheckOutcome.down("Quick Search diagnostics unavailable"));
}
}
/*******************************************************************************
** Cluster health is independent of backlog and per-index degradation.
*******************************************************************************/
private static HealthCheckOutcome opensearch(QuickSearchStatus status)
{
if(status.state() == QuickSearchStatus.State.DISABLED)
{
return (HealthCheckOutcome.unknown("Quick Search is disabled"));
}
HealthCheckOutcome outcome;
if(status.state() == QuickSearchStatus.State.DOWN || Boolean.FALSE.equals(status.clusterReachable()) || "red".equals(status.clusterHealth()))
{
outcome = HealthCheckOutcome.down("OpenSearch is unavailable or red");
}
else if(Boolean.TRUE.equals(status.clusterReachable()) && "green".equals(status.clusterHealth()))
{
outcome = HealthCheckOutcome.up("OpenSearch is green");
}
else if(Boolean.TRUE.equals(status.clusterReachable()) && QuickSearchStatus.isSingleNodeReplicaYellow(status.clusterHealth(), status.nodes(), status.unassignedReplicaShards()))
{
outcome = HealthCheckOutcome.up("OpenSearch is yellow only because replicas cannot be assigned on a single node");
outcome.getDetails().withValue("note", "replicas cannot be assigned on a single-node cluster");
}
else
{
outcome = HealthCheckOutcome.degraded("OpenSearch health is yellow or incomplete");
}
detail(outcome, "reachable", status.clusterReachable());
detail(outcome, "nodes", status.nodes());
detail(outcome, "activeShards", status.activeShards());
detail(outcome, "unassignedShards", status.unassignedShards());
detail(outcome, "unassignedReplicaShards", status.unassignedReplicaShards());
detail(outcome, "targetReplicas", status.targetReplicas());
detail(outcome, "replicaSettingsFailed", status.replicaSettingsFailed());
return (outcome);
}
/*******************************************************************************
** Counts are lower bounds when truncated; do not declare an incomplete backlog healthy.
*******************************************************************************/
private static HealthCheckOutcome backlog(QuickSearchStatus status)
{
HealthCheckOutcome outcome;
if(status.state() == QuickSearchStatus.State.DISABLED)
{
outcome = HealthCheckOutcome.unknown("Quick Search is disabled");
}
else if(status.pending() == null || status.exhausted() == null || status.indexingPaused() == null)
{
outcome = HealthCheckOutcome.degraded("Indexing backlog measurements unavailable");
}
else if(status.indexingPaused() || status.exhausted() > 0 || status.pending() > 100 || Boolean.TRUE.equals(status.backlogTruncated()))
{
outcome = HealthCheckOutcome.degraded("Indexing is paused or backlog needs attention");
}
else
{
outcome = HealthCheckOutcome.up("Indexing backlog is within limits");
}
detail(outcome, "pending", status.pending());
detail(outcome, "exhausted", status.exhausted());
detail(outcome, "indexingPaused", status.indexingPaused());
detail(outcome, "backlogTruncated", status.backlogTruncated());
return (outcome);
}
/*******************************************************************************
** Missing rows (including those outside the bounded snapshot) stay unknown.
** Persisted error text is represented only by a boolean.
*******************************************************************************/
private static HealthCheckOutcome index(QuickSearchStatus status, String tableName)
{
IndexRow row = status.indexes().stream().filter(candidate -> tableName.equals(candidate.tableName())).findFirst().orElse(null);
if(row == null)
{
return (HealthCheckOutcome.unknown("Index measurements unavailable"));
}
boolean hasError = row.lastError() != null && !row.lastError().isBlank();
boolean mismatch = row.documentCount() != null && row.sourceCount() != null && !row.documentCount().equals(row.sourceCount());
HealthCheckOutcome outcome;
if(Boolean.FALSE.equals(row.enabled()))
{
outcome = HealthCheckOutcome.unknown("Index is disabled");
}
else if(!"ACTIVE".equals(row.status()) || hasError || mismatch || (row.realTimeErrorCount() != null && row.realTimeErrorCount() > 0))
{
outcome = HealthCheckOutcome.degraded("Index needs attention");
}
else
{
outcome = HealthCheckOutcome.up("Index is active");
}
outcome.withDetails(new HealthDetails().withValue("hasError", hasError).withValue("countMismatch", mismatch));
detail(outcome, "enabled", row.enabled());
detail(outcome, "documentCount", row.documentCount());
detail(outcome, "sourceCount", row.sourceCount());
detail(outcome, "countsCheckedAt", row.countsCheckedAt());
detail(outcome, "lastSuccessfulSync", row.lastSuccessfulSync());
detail(outcome, "realTimeErrorCount", row.realTimeErrorCount());
return (outcome);
}
/*******************************************************************************
** Unknown measurements are omitted rather than converted to zero.
*******************************************************************************/
private static void detail(HealthCheckOutcome outcome, String name, Object value)
{
if(value != null)
{
outcome.getDetails().withValue(name, value);
}
}
}
@@ -334,6 +334,8 @@ public class QuickSearchRecordChangeListener implements RecordChangeListenerInte
updateInput.setTableName(indexTable);
updateInput.setRecords(List.of(new QRecord().withValue("id", row.getValue("id"))
.withValue("realTimeErrorCount", Math.addExact(previous == null ? 0 : previous, entry.getValue()))));
// A runtime counter, not a configuration change: not record-audited.
updateInput.setOmitDmlAudit(true);
List<QRecord> updated = QuickSearchConfigWrites.asSystem(() -> new UpdateAction().execute(updateInput)).getRecords();
if(CollectionUtils.nullSafeIsEmpty(updated) || CollectionUtils.nullSafeHasContents(updated.get(0).getErrors()))
{
@@ -34,6 +34,8 @@ import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.quicksearch.QuickSearchAuthMode;
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
@@ -127,11 +129,26 @@ public class QuickSearchOpenSearchClient implements Closeable
private final String indexName;
private final int maxBulkRequestBytes;
private final ObjectMapper sizingMapper;
private final Integer indexReplicas;
private final Integer maxAutoReplicas;
private final Boolean replicasManaged;
private volatile Boolean mappingOutdated = false;
private volatile Boolean indexCreated = false;
private volatile Instant indexCreatedAt;
private Boolean closed = false;
private volatile ReplicaStatus replicaStatus;
/*******************************************************************************
** Outcome of the latest replica sizing: the target number_of_replicas, the
** data nodes it was sized for (null when explicitly configured), how many
** physical indexes were changed, and whether applying it failed.
*******************************************************************************/
public record ReplicaStatus(Integer targetReplicas, Integer dataNodes, Integer updatedIndexes, Boolean failed)
{
}
@@ -146,6 +163,9 @@ public class QuickSearchOpenSearchClient implements Closeable
this.indexName = config.getOpensearchIndexName();
this.maxBulkRequestBytes = config.getMaxBulkRequestBytes() == null ? 5 * 1024 * 1024 : config.getMaxBulkRequestBytes();
this.sizingMapper = new ObjectMapper().registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
this.indexReplicas = config.getIndexReplicas();
this.maxAutoReplicas = config.getMaxAutoReplicas() == null ? QuickSearchQBitConfig.DEFAULT_MAX_AUTO_REPLICAS : config.getMaxAutoReplicas();
this.replicasManaged = !(config.getEffectiveAuthMode() == QuickSearchAuthMode.AWS_SIGV4 && "aoss".equals(config.getEffectiveAwsServiceName()));
}
@@ -172,6 +192,7 @@ public class QuickSearchOpenSearchClient implements Closeable
LOG.info("OpenSearch index already exists", logPair("indexName", indexName));
extendMapping();
}
reconcileReplicas();
return;
}
@@ -314,13 +335,21 @@ public class QuickSearchOpenSearchClient implements Closeable
{
try
{
Integer replicas = replicasForNewIndex();
client.indices().create(CreateIndexRequest.of(r -> r
.index(physicalIndex)
.settings(s -> s
.analysis(a -> a
.settings(s ->
{
s.analysis(a -> a
.analyzer(INDEX_ANALYZER, an -> an.custom(c -> c.tokenizer("standard").filter("lowercase", "edge_ngram_filter")))
.analyzer(SEARCH_ANALYZER, an -> an.custom(c -> c.tokenizer("standard").filter("lowercase")))
.filter("edge_ngram_filter", f -> f.definition(d -> d.edgeNgram(en -> en.minGram(2).maxGram(20))))))
.filter("edge_ngram_filter", f -> f.definition(d -> d.edgeNgram(en -> en.minGram(2).maxGram(20)))));
if(replicas != null)
{
s.numberOfReplicas(replicas);
}
return (s);
})
.mappings(buildMapping())));
}
catch(OpenSearchException | java.io.IOException e)
@@ -331,6 +360,127 @@ public class QuickSearchOpenSearchClient implements Closeable
/*******************************************************************************
** Replicas a new physical index should ask for; null leaves the server
** default (Serverless, or the cluster could not be sized; logged).
*******************************************************************************/
private Integer replicasForNewIndex()
{
if(!Boolean.TRUE.equals(replicasManaged))
{
return (null);
}
try
{
ReplicaStatus target = targetReplicas();
replicaStatus = new ReplicaStatus(target.targetReplicas(), target.dataNodes(), 0, false);
return (target.targetReplicas());
}
catch(Exception e)
{
replicaStatus = new ReplicaStatus(null, null, 0, true);
LOG.warn("Could not size Quick Search index replicas; the new index uses the server default", e, logPair("indexName", indexName));
return (null);
}
}
/*******************************************************************************
** The configured indexReplicas, else min(maxAutoReplicas, data nodes - 1)
** from cluster health, so a single-node cluster gets 0 and stays green.
*******************************************************************************/
ReplicaStatus targetReplicas() throws QException
{
if(indexReplicas != null)
{
return (new ReplicaStatus(indexReplicas, null, 0, false));
}
QuickSearchDiagnosticsOperations.RawResponse health = new QuickSearchDiagnosticsOperations(this).raw("GET", "/_cluster/health", null);
QuickSearchDiagnosticsOperations.requireOk(health, "Cluster health unavailable");
JsonNode dataNodes = health.body() == null ? null : health.body().path("number_of_data_nodes");
if(dataNodes == null || !dataNodes.isNumber())
{
throw (new QException("Cluster health did not report number_of_data_nodes"));
}
return (new ReplicaStatus(autoReplicas(dataNodes.intValue(), maxAutoReplicas), dataNodes.intValue(), 0, false));
}
/*******************************************************************************
** Automatic replica count: a replica can only be assigned to a different
** data node than its primary, so never more than data nodes - 1.
*******************************************************************************/
static int autoReplicas(int dataNodes, int maxAutoReplicas)
{
return (Math.max(0, Math.min(maxAutoReplicas, dataNodes - 1)));
}
/*******************************************************************************
** Bring number_of_replicas of the physical index(es) behind the alias to
** the target. Idempotent (only differing indexes are updated) and failure
** tolerant: a failure is logged and kept in {@link #getReplicaStatus()},
** never thrown, so it cannot block startup. Indexes with
** auto_expand_replicas set are left to OpenSearch. Null on Serverless.
*******************************************************************************/
public ReplicaStatus reconcileReplicas()
{
if(!Boolean.TRUE.equals(replicasManaged))
{
return (null);
}
Integer target = null;
Integer nodes = null;
try
{
ReplicaStatus sized = targetReplicas();
target = sized.targetReplicas();
nodes = sized.dataNodes();
QuickSearchDiagnosticsOperations operations = new QuickSearchDiagnosticsOperations(this);
QuickSearchDiagnosticsOperations.RawResponse settings = operations.raw("GET", "/" + QuickSearchDiagnosticsOperations.encode(indexName) + "/_settings", null);
QuickSearchDiagnosticsOperations.requireOk(settings, "Index settings unavailable");
int updated = 0;
for(Map.Entry<String, JsonNode> entry : settings.body().properties())
{
JsonNode index = entry.getValue().path("settings").path("index");
String current = index.path("number_of_replicas").asText(null);
String autoExpand = index.path("auto_expand_replicas").asText("false");
if(!"false".equalsIgnoreCase(autoExpand) || String.valueOf(target).equals(current))
{
continue;
}
QuickSearchDiagnosticsOperations.RawResponse put = operations.raw("PUT", "/" + QuickSearchDiagnosticsOperations.encode(entry.getKey()) + "/_settings",
"{\"index\":{\"number_of_replicas\":" + target + "}}");
QuickSearchDiagnosticsOperations.requireOk(put, "Updating number_of_replicas failed");
updated++;
LOG.info("Updated Quick Search index replicas", logPair("physicalIndex", entry.getKey()), logPair("from", current),
logPair("to", target), logPair("dataNodes", nodes));
}
replicaStatus = new ReplicaStatus(target, nodes, updated, false);
}
catch(Exception e)
{
LOG.warn("Could not reconcile Quick Search index replicas; continuing", e, logPair("indexName", indexName), logPair("targetReplicas", target));
replicaStatus = new ReplicaStatus(target, nodes, 0, true);
}
return (replicaStatus);
}
/*******************************************************************************
** The latest replica sizing outcome; null before any (or on Serverless).
*******************************************************************************/
public ReplicaStatus getReplicaStatus()
{
return (replicaStatus);
}
/*******************************************************************************
**
*******************************************************************************/
@@ -493,7 +493,10 @@ public abstract class AbstractIndexingStep implements BackendStep
/*******************************************************************************
** Update fields on a quick_search_index row.
** Update runtime state on a quick_search_index row (status, last run, last
** basepull time, document count). Indexing bookkeeping, not a configuration
** change: it is not record-audited, or every basepull tick would write an
** audit row. Admin edits to the row go through the table and stay audited.
*******************************************************************************/
protected void updateIndexRow(Integer indexId, Map<String, ? extends Serializable> values) throws QException
{
@@ -506,6 +509,7 @@ public abstract class AbstractIndexingStep implements BackendStep
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(getConfig().getQuickSearchIndexTableName());
updateInput.setRecords(List.of(record));
updateInput.setOmitDmlAudit(true);
QuickSearchConfigWrites.asSystem(() -> new UpdateAction().execute(updateInput));
}
@@ -1,5 +1,5 @@
bundle: qbit-quick-search
version: 1.1.0-SNAPSHOT
version: ${project.version}
layer: QBIT
entries:
- key: app:quickSearchAdmin;slot:help
@@ -1,6 +1,46 @@
components:
schemas:
BasicErrorResponseV1:
description: "The error body of every UI API operation (core and extensions)."
properties:
error:
description: "The error message (the same as message; kept for clients of\
\ UI API 1.0 to 1.5)."
type: "string"
code:
description: "A stable, machine-readable code. Well-known values: BAD_REQUEST\
\ (400), UNAUTHENTICATED (401),\nPERMISSION_DENIED (403), NOT_FOUND (404),\
\ CONFLICT (409), RATE_LIMITED (429), INTERNAL_ERROR (500),\nERROR (other).\
\ Extensions may add codes; treat an unknown code by the HTTP status.\
\ Since 1.6.0."
type: "string"
message:
description: "A message to show the user. Since 1.6.0."
type: "string"
fieldErrors:
description: "Errors about particular request fields, when known. Since\
\ 1.6.0."
items:
properties:
field:
description: "The field (or parameter) name."
type: "string"
message:
description: "The error about that field."
type: "string"
code:
description: "An optional machine-readable code, e.g. REQUIRED, TOO_LONG,\
\ PATTERN."
type: "string"
type: "object"
type: "array"
traceId:
description: "Identifies this request in the server logs (also the X-QQQ-Trace-Id\
\ response header); quote it when reporting a problem. Since 1.6.0."
type: "string"
type: "object"
QuickSearchErrorV1:
description: "Deprecated since 1.1.0: errors use BasicErrorResponseV1."
properties:
error:
description: "Framework error message."
@@ -114,6 +154,11 @@ components:
\ backlog rows."
nullable: true
type: "integer"
replicaSettingsFailed:
description: "Sizing or applying the index replicas failed at startup; null\
\ when not attempted."
nullable: true
type: "boolean"
state:
description: "Overall availability; incomplete diagnostics are DEGRADED."
enum:
@@ -127,6 +172,16 @@ components:
description: "More than 100 index rows exist."
nullable: false
type: "boolean"
targetReplicas:
description: "number_of_replicas Quick Search applies to its index; null\
\ when unknown or not managed (Serverless)."
nullable: true
type: "integer"
unassignedReplicaShards:
description: "Unassigned shards when every primary is active (all are replicas);\
\ null when unknown or a primary is not active."
nullable: true
type: "integer"
unassignedShards:
description: "Cluster health counter; null when unavailable."
nullable: true
@@ -194,15 +249,48 @@ components:
type: "object"
securitySchemes:
sessionUuidCookie:
description: "The session cookie set by POST /qqq/v1/manageSession (web clients)."
in: "cookie"
name: "sessionUUID"
type: "apiKey"
bearerAuth:
bearerFormat: "JWT or session UUID"
description: "`Authorization: Bearer <token>`, where the token is either\n-\
\ an OAuth2 access token (a signed JWT) issued by the app's identity provider\
\ to the app's client,\n or to its native client (`values.nativeClientId`\
\ in GET /qqq/v1/metaData/authentication), for\n example from the device-authorization\
\ grant or auth-code + PKCE. It is verified against the\n provider's keys\
\ (issuer, audience, expiry, signature) and gives the same user and permissions\n\
\ as signing in on the web. When it expires (401), refresh it at the provider\
\ and retry; or\n- a session UUID returned by POST /qqq/v1/manageSession,\
\ which the server keeps refreshed."
scheme: "bearer"
type: "http"
oauth2:
description: "The app's OAuth2 / OpenID Connect provider. The real endpoints\
\ come from the provider's discovery\ndocument: `{values.baseUrl}.well-known/openid-configuration`,\
\ with values from\nGET /qqq/v1/metaData/authentication. Native clients use\
\ `values.nativeClientId` (a public client)\nwith auth-code + PKCE (loopback\
\ or custom-scheme redirect URI registered at the provider) or the\ndevice-authorization\
\ grant (`device_authorization_endpoint`, RFC 8628), then send the access\
\ token\nas `Authorization: Bearer`."
flows:
authorizationCode:
authorizationUrl: "/authorize"
scopes:
email: "Email address"
offline_access: "A refresh token, to renew the access token without signing\
\ in again"
openid: "OpenID Connect sign-in"
profile: "Name, username and groups (permissions come from the groups)"
tokenUrl: "/token"
type: "oauth2"
info:
contact:
email: "contact@kingsrook.com"
description: "Quick Search administration and suggestions"
title: "QQQ Middleware API extension: quick-search"
version: "1.0.0"
version: "1.1.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/quick-search/status:
@@ -217,19 +305,19 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/QuickSearchErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Diagnostic rate limit exceeded"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/QuickSearchErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/QuickSearchErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Quick Search admin permission required"
200:
content:
@@ -237,10 +325,37 @@ paths:
schema:
$ref: "#/components/schemas/QuickSearchStatusV1"
description: "Successful Response"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found, or not visible to this user (code NOT_FOUND)."
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Get Quick Search status"
tags:
- "quick-search"
/qqq/v1/qbit/quick-search/suggest:
get:
description: "Requires a signed-in session, not Quick Search admin permission.\
@@ -290,20 +405,20 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/QuickSearchErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Missing q, normalized term longer than 100 characters, or\
\ invalid limits"
401:
content:
application/json:
schema:
$ref: "#/components/schemas/QuickSearchErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Sign in required"
403:
content:
application/json:
schema:
$ref: "#/components/schemas/QuickSearchErrorV1"
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Session permission denied"
200:
content:
@@ -311,7 +426,40 @@ paths:
schema:
$ref: "#/components/schemas/QuickSearchSuggestV1"
description: "Successful Response"
404:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Not found, or not visible to this user (code NOT_FOUND)."
409:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Conflict with the current state, e.g. an edit conflict or\
\ an invalid transition (code CONFLICT)."
429:
content:
application/json:
schema:
$ref: "#/components/schemas/BasicErrorResponseV1"
description: "Too many requests (code RATE_LIMITED); retry later."
security:
- sessionUuidCookie:
- "N/A"
- bearerAuth:
- "N/A"
- oauth2:
- "openid"
- "profile"
- "email"
summary: "Get Quick Search suggestions"
tags:
- "quick-search"
servers:
- description: "The QQQ server that serves this document (paths include /qqq/v1)."
url: "/"
tags:
- description: "Operations of the quick-search extension."
name: "quick-search"
@@ -48,6 +48,7 @@ import com.kingsrook.qbits.quicksearch.annotations.QuickSearchField;
import com.kingsrook.qbits.quicksearch.annotations.QuickSearchable;
import com.kingsrook.qbits.quicksearch.opensearch.BulkIndexResult;
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchDiagnosticsOperations;
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient;
import com.kingsrook.qbits.quicksearch.processes.FullReindexStep;
import com.kingsrook.qbits.quicksearch.processes.ReconcileIndexStep;
@@ -218,6 +219,14 @@ class OpenSearchIntegrationTest
assertThat(physicalIndexes.iterator().next()).startsWith(INDEX_NAME + "-v" + QuickSearchOpenSearchClient.MAPPING_VERSION + "-");
assertThat(client().isMappingOutdated()).isFalse();
assertThat(client().isHealthy()).isTrue();
////////////////////////////////////////////////////////////////////
// zero config on a single node: no replica, so the index is green //
////////////////////////////////////////////////////////////////////
String physicalIndex = physicalIndexes.iterator().next();
QuickSearchDiagnosticsOperations operations = new QuickSearchDiagnosticsOperations(client());
assertThat(operations.settings().body().path(physicalIndex).path("settings").path("index").path("number_of_replicas").asText()).isEqualTo("0");
assertThat(operations.clusterHealth().body().path("status").asText()).isEqualTo("green");
}
@@ -520,6 +529,27 @@ class OpenSearchIntegrationTest
/*******************************************************************************
** Test 12: an index left with a replica it cannot place on one node (yellow)
** is brought back to 0 replicas (green) by the startup reconcile, once.
*******************************************************************************/
@Test
@Order(12)
void testReconcileReplicas_singleNodeDropsUnassignableReplica() throws Exception
{
String physicalIndex = client().getPhysicalIndexes().iterator().next();
QuickSearchDiagnosticsOperations operations = new QuickSearchDiagnosticsOperations(client());
assertThat(operations.raw("PUT", "/" + physicalIndex + "/_settings", "{\"index\":{\"number_of_replicas\":1}}").isOk()).isTrue();
assertThat(operations.clusterHealth().body().path("status").asText()).isEqualTo("yellow");
assertThat(client().reconcileReplicas()).isEqualTo(new QuickSearchOpenSearchClient.ReplicaStatus(0, 1, 1, false));
assertThat(operations.settings().body().path(physicalIndex).path("settings").path("index").path("number_of_replicas").asText()).isEqualTo("0");
assertThat(operations.clusterHealth().body().path("status").asText()).isEqualTo("green");
assertThat(client().reconcileReplicas()).isEqualTo(new QuickSearchOpenSearchClient.ReplicaStatus(0, 1, 0, false));
}
/*******************************************************************************
** Rebuild one table in place and refresh so its documents are searchable.
*******************************************************************************/
@@ -53,11 +53,14 @@ class QuickSearchApiContractTest
assertThat(UiApiContract.check(generated, Files.readString(path), null, 1,
"mvnw.cmd -Dtest=QuickSearchApiContractTest -Dquicksearch.openapi.update=true test")).isEmpty();
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("QuickSearch"));
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("QuickSearch") || name.equals("BasicErrorResponseV1"));
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder("/qqq/v1/qbit/quick-search/status", "/qqq/v1/qbit/quick-search/suggest");
assertThat(extension.getSpecs()).hasSize(2);
assertThat(generated).contains("getQuickSearchSuggestions", "QuickSearchSuggestV1", "QuickSearchSuggestGroupV1", "QuickSearchSuggestItemV1");
var schemas = model.getComponents().getSchemas();
assertThat(schemas.get("BasicErrorResponseV1").getProperties()).containsOnlyKeys("error", "code", "message", "fieldErrors", "traceId");
assertThat(schemas.get("QuickSearchErrorV1").getDescription()).isEqualTo("Deprecated since 1.1.0: errors use BasicErrorResponseV1.");
assertThat(generated).contains("getQuickSearchStatus").doesNotContain("#/components/schemas/QuickSearchErrorV1");
assertThat(schemas.get("QuickSearchSuggestV1").getProperties()).containsOnlyKeys("query", "groups", "truncated");
assertThat(schemas.get("QuickSearchSuggestGroupV1").getProperties()).containsOnlyKeys("tableName", "tableLabel", "hasMore", "items");
assertThat(schemas.get("QuickSearchSuggestItemV1").getProperties()).containsOnlyKeys("recordId", "label", "subtitle");
@@ -0,0 +1,193 @@
/*
* Copyright 2026 Kingsrook, LLC
*
* 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
*
* http://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.quicksearch;
import java.util.EnumSet;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
import com.kingsrook.qqq.backend.core.cluster.ClusterBus;
import com.kingsrook.qqq.backend.core.cluster.ClusterBusInterface;
import com.kingsrook.qqq.backend.core.cluster.ClusterBusMetaData;
import com.kingsrook.qqq.backend.core.cluster.ClusterCacheAudit;
import com.kingsrook.qqq.backend.core.cluster.ClusterEvent;
import com.kingsrook.qqq.backend.core.cluster.QNodeRoles;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndex;
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndexField;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** The Quick Search caches are correct on several nodes: every static cache is
** registered with ClusterCaches, and a config change on one node clears the
** config cache on the others.
*******************************************************************************/
class QuickSearchClusterCachesTest extends BaseQuickSearchTest
{
/** the "wire": events every node's transport sees */
static final List<Consumer<ClusterEvent>> WIRE = new CopyOnWriteArrayList<>();
/*******************************************************************************
** A transport over the static WIRE, so a "remote" publish reaches us.
*******************************************************************************/
public static class WireBus implements ClusterBusInterface
{
/*******************************************************************************
**
*******************************************************************************/
@Override
public void publish(ClusterEvent event)
{
WIRE.forEach(listener -> listener.accept(event));
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public AutoCloseable subscribe(Consumer<ClusterEvent> listener)
{
WIRE.add(listener);
return (() -> WIRE.remove(listener));
}
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void afterEach()
{
ClusterBus.close(QContext.getQInstance());
WIRE.clear();
QNodeRoles.set(null);
}
/*******************************************************************************
** Connect the current instance to the wire as node-a.
*******************************************************************************/
private static void connectAsNodeA()
{
QInstance instance = QContext.getQInstance();
instance.withSupplementalMetaData(new ClusterBusMetaData().withBus(new QCodeReference(WireBus.class)));
QNodeRoles.set(new QNodeRoles.Settings(EnumSet.allOf(QNodeRoles.Role.class), true, true, true, null, "node-a"));
ClusterBus.connect(instance);
}
/*******************************************************************************
** A runtime with one code-declared table.
*******************************************************************************/
private static QuickSearchRuntime newRuntime()
{
///////////////////////////////////////////////////////////////////////
// without the field table the config comes from code, so the enabled //
// flag is read from the index row and cached //
///////////////////////////////////////////////////////////////////////
QContext.getQInstance().getTables().remove(QuickSearchIndexField.TABLE_NAME);
QContext.getQInstance().getTable(QuickSearchIndex.TABLE_NAME).getAssociations().clear();
QuickSearchRuntime runtime = new QuickSearchRuntime(new QuickSearchQBitConfig().withBackendName(TEST_BACKEND_NAME).withOpensearchHost("localhost").withOpensearchPort(9200).withOpensearchIndexName("t"));
runtime.setDiscoveredTables(List.of(new QuickSearchableTableConfig().withTableName(TEST_ENTITY_TABLE).withPrimaryKeyField("id").withSearchableFields(List.of("name"))));
return (runtime);
}
/*******************************************************************************
** Every static Memoization in the QBit is registered (shared or node-local).
*******************************************************************************/
@Test
void testNoUnregisteredStaticCaches() throws Exception
{
assertThat(ClusterCacheAudit.unregisteredStaticCaches("com.kingsrook.qbits.quicksearch")).isEmpty();
}
/*******************************************************************************
** Another node's config invalidation clears this node's cached config.
*******************************************************************************/
@Test
void testRemoteInvalidationClearsConfigCache()
{
connectAsNodeA();
QuickSearchRuntime runtime = newRuntime();
try
{
runtime.isTableEnabled(TEST_ENTITY_TABLE);
assertThat(runtime.getCachedEnabledFlagCount()).isEqualTo(1);
ClusterEvent remote = new ClusterEvent(ClusterEvent.TYPE_MEMO_INVALIDATE, QuickSearchRuntime.CONFIG_CACHE_NAME, null);
remote.setOriginNodeId("node-b");
WIRE.forEach(listener -> listener.accept(remote));
assertThat(runtime.getCachedEnabledFlagCount()).isZero();
}
finally
{
runtime.close();
}
}
/*******************************************************************************
** A config change here clears the cache here and tells the other nodes.
*******************************************************************************/
@Test
void testInvalidateConfigPublishesToOtherNodes()
{
connectAsNodeA();
List<ClusterEvent> sent = new CopyOnWriteArrayList<>();
WIRE.add(sent::add);
QuickSearchRuntime runtime = newRuntime();
try
{
runtime.isTableEnabled(TEST_ENTITY_TABLE);
runtime.invalidateConfig();
assertThat(runtime.getCachedEnabledFlagCount()).isZero();
assertThat(sent).anySatisfy(event ->
{
assertThat(event.getType()).isEqualTo(ClusterEvent.TYPE_MEMO_INVALIDATE);
assertThat(event.getName()).isEqualTo(QuickSearchRuntime.CONFIG_CACHE_NAME);
assertThat(event.getOriginNodeId()).isEqualTo("node-a");
});
}
finally
{
runtime.close();
}
}
}
@@ -348,7 +348,11 @@ class QuickSearchQBitConfigValidationTest
.withBasepullOverlapSeconds(-5)
.withRunHistoryRetentionDays(0)
.withFullReindexStaleMinutes(0)
.withMaxFieldLength(0));
.withMaxFieldLength(0)
.withIndexReplicas(-1)
.withMaxAutoReplicas(-1));
assertThat(errors).anyMatch(e -> e.startsWith("indexReplicas"));
assertThat(errors).anyMatch(e -> e.startsWith("maxAutoReplicas"));
assertThat(errors).anyMatch(e -> e.startsWith("connectTimeoutMillis"));
assertThat(errors).anyMatch(e -> e.startsWith("responseTimeoutMillis"));
assertThat(errors).anyMatch(e -> e.startsWith("maxConnections"));
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndex;
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndexField;
import com.kingsrook.qbits.quicksearch.publisher.IndexEventPublisher;
@@ -223,4 +224,31 @@ class QuickSearchRuntimeTest extends BaseQuickSearchTest
failFast.close();
}
/*******************************************************************************
** Priming at startup without a QContext builds the system session with the
** start instance (so it has the all-access keys), not from the empty context.
*******************************************************************************/
@Test
void primeConfigWithoutAContextGivesTheSystemSessionItsInstance()
{
QInstance qInstance = QContext.getQInstance();
QuickSearchRuntime runtime = new QuickSearchRuntime(new QuickSearchQBitConfig().withBackendName(TEST_BACKEND_NAME).withOpensearchHost("127.0.0.1").withOpensearchPort(1).withOpensearchIndexName("t"));
QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(QSystemUserSession.class);
try
{
runtime.setStartInstance(qInstance);
QContext.clear();
runtime.primeConfig();
}
finally
{
QLogger.deactivateCollectingLoggerForClass(QSystemUserSession.class);
QContext.init(qInstance, new QSystemUserSession(qInstance));
runtime.close();
}
assertThat(collectingLogger.getCollectedMessages()).noneSatisfy(m -> assertThat(m.getMessage()).contains("QInstance was not supplied"));
}
}
@@ -80,6 +80,7 @@ class QuickSearchStatusTest
private QuickSearchQBitConfig config;
private QInstance instance;
private volatile String health = "green";
private volatile String extraHealth = ",\"number_of_nodes\":1,\"active_shards\":1,\"unassigned_shards\":0";
private volatile int requests;
private CountDownLatch blocked;
@@ -106,7 +107,7 @@ class QuickSearchStatusTest
Thread.currentThread().interrupt();
}
}
byte[] body = ("{\"status\":\"" + health + "\",\"number_of_nodes\":1,\"active_shards\":1,\"unassigned_shards\":0}").getBytes(StandardCharsets.UTF_8);
byte[] body = ("{\"status\":\"" + health + "\"" + extraHealth + "}").getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
@@ -214,6 +215,36 @@ class QuickSearchStatusTest
/*******************************************************************************
** Yellow from replicas a single node cannot hold is OK; an inactive primary
** or a second node keeps it DEGRADED.
*******************************************************************************/
@Test
void singleNodeReplicaYellow() throws Exception
{
health = "yellow";
String index = ",\"indices\":{\"status-test-v2-1\":{\"number_of_shards\":1,\"active_primary_shards\":%d}}";
extraHealth = ",\"number_of_nodes\":1,\"active_shards\":1,\"unassigned_shards\":1,\"initializing_shards\":0" + index.formatted(1);
QuickSearchStatus single = StatusDiagnostics.read(config.getRuntime());
assertThat(single.state()).isEqualTo(QuickSearchStatus.State.OK);
assertThat(single.unassignedReplicaShards()).isEqualTo(1);
extraHealth = ",\"number_of_nodes\":1,\"active_shards\":0,\"unassigned_shards\":2,\"initializing_shards\":0" + index.formatted(0);
QuickSearchStatus primaryMissing = StatusDiagnostics.read(config.getRuntime());
assertThat(primaryMissing.state()).isEqualTo(QuickSearchStatus.State.DEGRADED);
assertThat(primaryMissing.unassignedReplicaShards()).isNull();
extraHealth = ",\"number_of_nodes\":1,\"active_shards\":0,\"unassigned_shards\":1,\"initializing_shards\":1" + index.formatted(1);
assertThat(StatusDiagnostics.read(config.getRuntime()).unassignedReplicaShards()).isNull();
extraHealth = ",\"number_of_nodes\":2,\"active_shards\":1,\"unassigned_shards\":1,\"initializing_shards\":0" + index.formatted(1);
QuickSearchStatus multi = StatusDiagnostics.read(config.getRuntime());
assertThat(multi.state()).isEqualTo(QuickSearchStatus.State.DEGRADED);
assertThat(multi.unassignedReplicaShards()).isEqualTo(1);
}
/*******************************************************************************
** A blocked remote call has bounded caller latency and cannot grow a queue.
*******************************************************************************/
@@ -362,6 +393,14 @@ class QuickSearchStatusTest
assertThat(anonymous.statusCode()).as(anonymous.body()).isEqualTo(403);
var denied = client.send(HttpRequest.newBuilder(URI.create(url)).header("Cookie", "sessionUUID=reader-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString());
assertThat(denied.statusCode()).as(denied.body()).isEqualTo(403);
for(var forbidden : List.of(anonymous, denied))
{
var error = new ObjectMapper().readTree(forbidden.body());
assertThat(error.path("error").asText()).isNotBlank();
assertThat(error.path("message")).isEqualTo(error.path("error"));
assertThat(error.path("code").asText()).isEqualTo("PERMISSION_DENIED");
assertThat(error.path("traceId").asText()).isNotBlank();
}
String suggestUrl = "http://127.0.0.1:" + port + "/qqq/v1/qbit/quick-search/suggest";
for(String query : List.of("?q=x", "?q=%20x%20", "?q=wi&tables=unknown", "?q=wi&tables=", "?q=wi"))
{
@@ -378,7 +417,11 @@ class QuickSearchStatusTest
var invalid = client.send(HttpRequest.newBuilder(URI.create(suggestUrl + query))
.header("Cookie", "sessionUUID=reader-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString());
assertThat(invalid.statusCode()).as(invalid.body()).isEqualTo(400);
assertThat(new ObjectMapper().readTree(invalid.body()).path("error").isTextual()).isTrue();
var error = new ObjectMapper().readTree(invalid.body());
assertThat(error.path("error").asText()).isNotBlank();
assertThat(error.path("message")).isEqualTo(error.path("error"));
assertThat(error.path("code").asText()).isEqualTo("BAD_REQUEST");
assertThat(error.path("traceId").asText()).isNotBlank();
}
var openapi = client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/qqq/v1/openapi.yaml")).GET().build(), HttpResponse.BodyHandlers.ofString());
assertThat(openapi.body()).contains("/qqq/v1/qbit/quick-search/status", "QuickSearchStatusV1",
@@ -21,7 +21,9 @@ import java.util.List;
import java.util.Map;
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.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
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.data.QRecord;
@@ -77,6 +79,38 @@ class QuickSearchConfigPrimerTest extends BaseDataDrivenTest
/*******************************************************************************
** Re-priming only records the priming outcome on the settings row: that is
** not audited. An admin settings edit still is.
*******************************************************************************/
@Test
void testRepriming_settingsBookkeepingIsNotAudited() throws QException
{
new AuditsMetaDataProvider().defineAll(QContext.getQInstance(), TEST_BACKEND_NAME, null);
new QuickSearchConfigPrimer(runtime).prime();
long afterFirst = settingsEditAudits();
new QuickSearchConfigPrimer(runtime).prime();
assertThat(settingsEditAudits()).as("re-priming is not audited").isEqualTo(afterFirst);
new UpdateAction().execute(new UpdateInput().withTableName(QuickSearchSettings.TABLE_NAME)
.withRecord(new QRecord().withValue("id", QuickSearchSettings.SETTINGS_ID).withValue("indexingPaused", true)));
assertThat(settingsEditAudits()).as("an admin settings edit is audited").isEqualTo(afterFirst + 1);
}
/*******************************************************************************
** Audit rows recording an edit of the settings row.
*******************************************************************************/
private long settingsEditAudits() throws QException
{
return (rows(AuditsMetaDataProvider.TABLE_NAME_AUDIT).stream()
.filter(audit -> audit.getValueString("message") != null && audit.getValueString("message").contains("Edited"))
.count());
}
/*******************************************************************************
** Code defaults update rows nobody edited, and never rows an owner edited.
*******************************************************************************/
@@ -0,0 +1,442 @@
/*
* 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.quicksearch.health;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qbits.quicksearch.BaseQuickSearchTest;
import com.kingsrook.qbits.quicksearch.QuickSearchAuthMode;
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
import com.kingsrook.qbits.quicksearch.QuickSearchQBitProducer;
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
import com.kingsrook.qbits.quicksearch.QuickSearchStartupMode;
import com.kingsrook.qbits.quicksearch.SearchableFieldConfig;
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatus;
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatus.IndexRow;
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatus.State;
import com.kingsrook.qbits.quicksearch.config.QuickSearchConfigPrimer;
import com.kingsrook.qbits.quicksearch.diagnostics.StatusCache;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.health.HealthCategory;
import com.kingsrook.qqq.backend.core.health.HealthCheckInput;
import com.kingsrook.qqq.backend.core.health.HealthCheckKind;
import com.kingsrook.qqq.backend.core.health.HealthCheckMetaData;
import com.kingsrook.qqq.backend.core.health.HealthCheckOutcome;
import com.kingsrook.qqq.backend.core.health.HealthChecksMetaData;
import com.kingsrook.qqq.backend.core.health.HealthStatus;
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.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/*******************************************************************************
** Registration and projections use memory records and mocked diagnostics only.
*******************************************************************************/
class QuickSearchHealthChecksTest extends BaseQuickSearchTest
{
private static final String SENTINEL = "S3NT1NEL-secret";
private QInstance instance;
private QuickSearchQBitConfig config;
private QuickSearchRuntime runtime;
private QuickSearchRuntime originalRuntime;
private StatusCache cache;
/*******************************************************************************
** Exercise the real producer, with credentials that must never enter outcomes.
*******************************************************************************/
@BeforeEach
void setUpHealth() throws Exception
{
QInstance base = QContext.getQInstance();
instance = new QInstance();
instance.addBackend(base.getBackend(TEST_BACKEND_NAME));
instance.addTable(base.getTable(TEST_ENTITY_TABLE));
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
QContext.init(instance, new QSystemUserSession());
config = new QuickSearchQBitConfig().withBackendName(TEST_BACKEND_NAME).withStartupMode(QuickSearchStartupMode.DEGRADED)
.withEnableRealTimeIndexing(false).withOpensearchUrl("https://unused.invalid")
.withOpensearchIndexName("health-test").withSearchableTable(TEST_ENTITY_TABLE, List.of(new SearchableFieldConfig("name")))
.withAuthMode(QuickSearchAuthMode.BASIC).withOpensearchUsername(SENTINEL).withOpensearchPassword(SENTINEL);
new QuickSearchQBitProducer().withConfig(config).produce(instance);
QuickSearchConfigPrimer.insertOne(config.getQuickSearchIndexTableName(), new QRecord().withValue("tableName", TEST_ENTITY_TABLE)
.withValue("enabled", true).withValue("status", "ACTIVE").withValue("documentCount", 12));
originalRuntime = config.getRuntime();
runtime = spy(originalRuntime);
cache = mock(StatusCache.class);
doReturn(cache).when(runtime).getStatusCache();
config.setRuntime(runtime);
}
/*******************************************************************************
** Release the producer's real worker even when tests replace the cache.
*******************************************************************************/
@AfterEach
void closeHealth()
{
if(originalRuntime != null)
{
originalRuntime.close();
}
}
/*******************************************************************************
** Static checks and dynamically resolved indexes have the specified metadata.
*******************************************************************************/
@Test
void registration() throws Exception
{
QuickSearchHealthChecks.register(instance);
HealthChecksMetaData registry = HealthChecksMetaData.ofOrWithNew(instance);
assertThat(registry.getChecks()).extracting(HealthCheckMetaData::getName)
.containsExactly("quick_search.opensearch", "quick_search.backlog");
assertThat(registry.getSuppliers()).singleElement().satisfies(supplier ->
{
assertThat(supplier.getNamePrefix()).isEqualTo("quick_search.index");
assertThat(supplier.getComponent()).isEqualTo("quick_search");
});
assertThat(registry.resolveAll(instance)).hasSize(3).allSatisfy(check ->
{
assertThat(check.getName()).matches("[a-z0-9]+([._-][a-z0-9]+)*");
assertThat(check.getKind()).isEqualTo(HealthCheckKind.DEPENDENCY);
assertThat(check.getCategory()).isEqualTo(HealthCategory.SEARCH);
assertThat(check.getComponent()).isEqualTo("quick_search");
assertThat(check.getIntervalSeconds()).isEqualTo(60);
assertThat(check.getTimeoutMs()).isEqualTo(5000);
});
assertThat(registry.resolveAll(instance)).extracting(HealthCheckMetaData::getName).contains("quick_search.index.testentity");
verifyNoInteractions(cache);
}
/*******************************************************************************
** Overall degradation from indexing must not downgrade a healthy cluster.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"OK,true,green,UP", "DEGRADED,true,green,UP", "DEGRADED,true,yellow,DEGRADED", "DOWN,true,red,DOWN",
"DOWN,false,,DOWN", "DISABLED,,,UNKNOWN", "DEGRADED,,,DEGRADED", "OK,true,unexpected,DEGRADED"})
void clusterStates(State state, Boolean reachable, String color, HealthStatus expected) throws Exception
{
snapshot(state, reachable, color, false, 0, 0, false, List.of(row(true, "ACTIVE", 12L, 12L, null, 0)));
assertThat(run("opensearch").getStatus()).isEqualTo(expected);
}
/*******************************************************************************
** Yellow is healthy only on one node with every unassigned shard a replica.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"yellow,1,1,UP", "yellow,1,0,DEGRADED", "yellow,1,,DEGRADED", "yellow,2,1,DEGRADED", "yellow,,1,DEGRADED",
"green,1,0,UP", "red,1,1,DOWN"})
void singleNodeReplicas(String color, Integer nodes, Integer unassignedReplicas, HealthStatus expected) throws Exception
{
when(cache.get(eq(runtime), any(Instant.class))).thenReturn(new QuickSearchStatus(State.OK, true, color, nodes, 1, 1, unassignedReplicas, 0, false,
false, List.of(row(true, "ACTIVE", 12L, 12L, null, 0)), 0, 0, false, false, null, SENTINEL, Instant.now(), "/unused"));
HealthCheckOutcome outcome = run("opensearch");
assertThat(outcome.getStatus()).isEqualTo(expected);
assertThat(outcome.getDetails().toJson()).contains("\"targetReplicas\":0", "\"replicaSettingsFailed\":false");
if(expected == HealthStatus.UP && "yellow".equals(color))
{
assertThat(outcome.getDetails().toJson()).contains("\"unassignedReplicaShards\":1", "replicas cannot be assigned on a single-node cluster");
}
assertClean(outcome);
}
/*******************************************************************************
** Boundary values, exhausted events, pause, truncation and missing measurements.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"OK,false,0,0,false,UP", "DEGRADED,false,100,0,false,UP", "DEGRADED,false,101,0,false,DEGRADED",
"DEGRADED,false,0,1,false,DEGRADED", "DEGRADED,true,0,0,false,DEGRADED", "OK,false,0,0,true,DEGRADED",
"DEGRADED,false,,0,false,DEGRADED", "DEGRADED,false,0,,false,DEGRADED", "DEGRADED,,0,0,false,DEGRADED",
"DISABLED,false,0,0,false,UNKNOWN", "DOWN,false,0,0,false,UP"})
void backlogStates(State state, Boolean paused, Integer pending, Integer exhausted, Boolean truncated, HealthStatus expected) throws Exception
{
snapshot(state, true, "green", paused, pending, exhausted, truncated, List.of());
assertThat(run("backlog").getStatus()).isEqualTo(expected);
}
/*******************************************************************************
** Counts, index status, last errors and real-time errors each affect the check.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"true,ACTIVE,12,12,,0,UP", "false,ACTIVE,12,12,,0,UNKNOWN", "true,REBUILDING,12,12,,0,DEGRADED",
"true,ACTIVE,12,13,,0,DEGRADED", "true,ACTIVE,12,12,error,0,DEGRADED", "true,ACTIVE,12,12,,2,DEGRADED",
"true,ACTIVE,12,,,0,UP", "true,ACTIVE,,12,,,UP", "true,,12,12,,0,DEGRADED"})
void indexStates(Boolean enabled, String state, Long documents, Long source, String error, Integer realTimeErrors, HealthStatus expected) throws Exception
{
snapshot(State.OK, true, "green", false, 0, 0, false, List.of(row(enabled, state, documents, source, error, realTimeErrors)));
HealthCheckOutcome outcome = run("index.testentity");
assertThat(outcome.getStatus()).isEqualTo(expected);
if(realTimeErrors != null)
{
assertThat(outcome.getDetails().getValues()).containsEntry("realTimeErrorCount", realTimeErrors);
}
}
/*******************************************************************************
** Supplier identities are refreshed; checks never retain old measurements.
*******************************************************************************/
@Test
void dynamicRowsAndMissingSnapshot() throws Exception
{
HealthCheckMetaData check = check("index.testentity");
snapshot(State.OK, true, "green", false, 0, 0, false, List.of(row(true, "ACTIVE", 12L, 12L, null, 0)));
assertThat(check.getCheckInstance().run(input()).getStatus()).isEqualTo(HealthStatus.UP);
snapshot(State.OK, true, "green", false, 0, 0, false, List.of());
assertThat(check.getCheckInstance().run(input()).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
QuickSearchConfigPrimer.insertOne(config.getQuickSearchIndexTableName(), new QRecord().withValue("tableName", "New.Index-Name")
.withValue("enabled", false).withValue("status", "ACTIVE"));
assertThat(HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance)).extracting(HealthCheckMetaData::getName)
.contains("quick_search.index.new.index-name");
}
/*******************************************************************************
** Raw outcomes omit poisoned diagnostics and exceptions; sanitize is package-private in core.
*******************************************************************************/
@Test
void noSecrets() throws Exception
{
List<HealthCheckMetaData> checks = HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance);
snapshot(State.DEGRADED, true, SENTINEL, true, 101, 1, false, List.of(row(true, SENTINEL, 12L, 13L, SENTINEL, 9)));
for(HealthCheckMetaData check : checks)
{
assertClean(check.getCheckInstance().run(input()));
}
when(cache.get(eq(runtime), any(Instant.class))).thenThrow(new QException("Failure " + SENTINEL));
for(HealthCheckMetaData check : checks)
{
HealthCheckOutcome outcome = check.getCheckInstance().run(input());
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
assertClean(outcome);
}
}
/*******************************************************************************
** An expired deadline does no diagnostic work; missing runtime is UNKNOWN.
*******************************************************************************/
@Test
void expiredAndUnconfigured() throws Exception
{
for(HealthCheckMetaData check : HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance))
{
assertThat(check.getCheckInstance().run(input().withDeadline(Instant.now().minusSeconds(1))).getStatus()).isEqualTo(HealthStatus.DOWN);
assertThat(check.getCheckInstance().run(input().withQInstance(new QInstance())).getStatus()).isEqualTo(HealthStatus.UNKNOWN);
}
assertThat(new QuickSearchHealthChecks().supply(new QInstance())).isEmpty();
verifyNoInteractions(cache);
}
/*******************************************************************************
** The real cache also removes exception text from a failed diagnostic refresh.
*******************************************************************************/
@Test
void realCacheFailureIsClean() throws Exception
{
doReturn(originalRuntime.getStatusCache()).when(runtime).getStatusCache();
doThrow(new QException("Failed " + SENTINEL)).when(runtime).getClient();
assertThat(run("opensearch").getStatus()).isEqualTo(HealthStatus.DOWN);
for(HealthCheckMetaData check : HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance))
{
assertClean(check.getCheckInstance().run(input()));
}
}
/*******************************************************************************
** A stuck refresh cannot hold another caller beyond its shorter deadline.
*******************************************************************************/
@Test
void cacheWaitAndLockRespectDeadline() throws Exception
{
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
doReturn(originalRuntime.getStatusCache()).when(runtime).getStatusCache();
org.mockito.Mockito.doAnswer(invocation ->
{
entered.countDown();
release.await(5, TimeUnit.SECONDS);
throw (new QException("Failed " + SENTINEL));
}).when(runtime).getClient();
try(var executor = Executors.newSingleThreadExecutor())
{
var first = executor.submit(() ->
{
QContext.init(instance, new QSystemUserSession());
try
{
return (runtime.getStatusCache().get(runtime, Instant.now().plusSeconds(2)));
}
finally
{
QContext.clear();
}
});
try
{
assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue();
Instant started = Instant.now();
assertThatThrownBy(() -> runtime.getStatusCache().get(runtime, started.plusMillis(100))).isInstanceOf(QException.class);
assertThat(Instant.now()).isBefore(started.plusSeconds(1));
}
finally
{
release.countDown();
}
first.get(3, TimeUnit.SECONDS);
}
assertThatThrownBy(() -> runtime.getStatusCache().get(runtime, Instant.now().minusSeconds(1))).isInstanceOf(QException.class);
}
/*******************************************************************************
** A short health deadline bounds the refresh wait as well as lock contention.
*******************************************************************************/
@Test
void refreshWaitRespectsDeadline() throws Exception
{
CountDownLatch release = new CountDownLatch(1);
doReturn(originalRuntime.getStatusCache()).when(runtime).getStatusCache();
org.mockito.Mockito.doAnswer(invocation ->
{
release.await(5, TimeUnit.SECONDS);
throw (new QException("Failed " + SENTINEL));
}).when(runtime).getClient();
try
{
HealthCheckMetaData check = check("opensearch");
Instant started = Instant.now();
HealthCheckOutcome outcome = check.getCheckInstance().run(input().withDeadline(started.plusMillis(100)));
assertThat(Instant.now()).isBefore(started.plusSeconds(1));
assertThat(outcome.getStatus()).isEqualTo(HealthStatus.DOWN);
assertClean(outcome);
}
finally
{
release.countDown();
}
}
/*******************************************************************************
** Supply a deterministic snapshot without creating a network transport.
*******************************************************************************/
private void snapshot(State state, Boolean reachable, String color, Boolean paused, Integer pending, Integer exhausted, Boolean truncated, List<IndexRow> rows) throws Exception
{
when(cache.get(eq(runtime), any(Instant.class))).thenReturn(new QuickSearchStatus(state, reachable, color, 1, 2, 0, paused, rows,
pending, exhausted, truncated, false, null, SENTINEL, Instant.now(), "/unused"));
}
/*******************************************************************************
** A row whose identity is deliberately mixed case.
*******************************************************************************/
private IndexRow row(Boolean enabled, String state, Long documents, Long source, String error, Integer realTimeErrors)
{
return (new IndexRow(TEST_ENTITY_TABLE, enabled, state, documents, source, Instant.now(), Instant.now(), error, realTimeErrors));
}
/*******************************************************************************
** Resolve through the actual core registry, including supplier validation.
*******************************************************************************/
private HealthCheckMetaData check(String suffix) throws Exception
{
return (HealthChecksMetaData.ofOrWithNew(instance).resolveAll(instance).stream()
.filter(check -> check.getName().equals("quick_search." + suffix)).findFirst().orElseThrow());
}
/*******************************************************************************
** Run one registered check with a bounded input.
*******************************************************************************/
private HealthCheckOutcome run(String suffix) throws Exception
{
return (check(suffix).getCheckInstance().run(input()));
}
/*******************************************************************************
** The runner always supplies an instance and absolute deadline.
*******************************************************************************/
private HealthCheckInput input()
{
return (new HealthCheckInput().withQInstance(instance).withDeadline(Instant.now().plusSeconds(5)));
}
/*******************************************************************************
** Assert the raw public boundary, without relying on core's later redaction.
*******************************************************************************/
private void assertClean(HealthCheckOutcome outcome)
{
assertThat(outcome.getMessage()).doesNotContain(SENTINEL);
assertThat(outcome.getDetails().toJson()).doesNotContain(SENTINEL);
}
}
@@ -0,0 +1,274 @@
/*
* Copyright 2024 Kingsrook, LLC
*
* 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
*
* http://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.quicksearch.opensearch;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.quicksearch.QuickSearchAuthMode;
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient.ReplicaStatus;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Replica sizing and reconcile against a stub OpenSearch HTTP server.
*******************************************************************************/
class QuickSearchReplicasTest
{
private HttpServer server;
private List<String> requests;
private List<String> bodies;
private Integer dataNodes;
private String settings;
private Integer settingsStatus;
private Integer putStatus;
/*******************************************************************************
** Stub: _cluster/health reports dataNodes, GET _settings returns settings,
** PUT _settings returns putStatus; every request is recorded.
*******************************************************************************/
@BeforeEach
void setUp() throws IOException
{
requests = new ArrayList<>();
bodies = new ArrayList<>();
dataNodes = 1;
settingsStatus = 200;
putStatus = 200;
settings = settingsBody("1", null);
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", this::handle);
server.start();
}
/*******************************************************************************
**
*******************************************************************************/
@AfterEach
void tearDown()
{
server.stop(0);
}
/*******************************************************************************
**
*******************************************************************************/
private void handle(HttpExchange exchange) throws IOException
{
String request = exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath();
requests.add(request);
bodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
int status = 200;
String body = "{}";
if(request.equals("GET /_cluster/health"))
{
body = dataNodes == null ? "{\"status\":\"green\"}" : "{\"status\":\"yellow\",\"number_of_data_nodes\":" + dataNodes + "}";
}
else if(request.startsWith("GET ") && request.endsWith("/_settings"))
{
status = settingsStatus;
body = status == 200 ? settings : "{\"error\":{\"reason\":\"nope\"}}";
}
else if(request.startsWith("PUT ") && request.endsWith("/_settings"))
{
status = putStatus;
body = status == 200 ? "{\"acknowledged\":true}" : "{\"error\":{\"reason\":\"blocked\"}}";
}
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
/*******************************************************************************
**
*******************************************************************************/
private static String settingsBody(String replicas, String autoExpand)
{
return ("{\"alias-v2-1\":{\"settings\":{\"index\":{\"number_of_shards\":\"1\",\"number_of_replicas\":\"" + replicas + "\""
+ (autoExpand == null ? "" : ",\"auto_expand_replicas\":\"" + autoExpand + "\"") + "}}}}");
}
/*******************************************************************************
**
*******************************************************************************/
private QuickSearchQBitConfig config()
{
return (new QuickSearchQBitConfig().withOpensearchUrl("http://127.0.0.1:" + server.getAddress().getPort()).withOpensearchIndexName("alias"));
}
/*******************************************************************************
** Automatic sizing never exceeds data nodes - 1 nor maxAutoReplicas.
*******************************************************************************/
@ParameterizedTest
@CsvSource({"1,1,0", "2,1,1", "3,1,1", "3,2,2", "5,2,2", "0,1,0", "4,0,0"})
void autoReplicas(int nodes, int max, int expected)
{
assertThat(QuickSearchOpenSearchClient.autoReplicas(nodes, max)).isEqualTo(expected);
}
/*******************************************************************************
** Zero config on one node: the replica is dropped, once.
*******************************************************************************/
@Test
void singleNodeDropsReplicaIdempotently() throws Exception
{
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config()))
{
ReplicaStatus status = client.reconcileReplicas();
assertThat(status).isEqualTo(new ReplicaStatus(0, 1, 1, false));
assertThat(client.getReplicaStatus()).isEqualTo(status);
assertThat(requests).contains("PUT /alias-v2-1/_settings");
assertThat(bodies.get(requests.indexOf("PUT /alias-v2-1/_settings"))).isEqualTo("{\"index\":{\"number_of_replicas\":0}}");
settings = settingsBody("0", null);
requests.clear();
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(0, 1, 0, false));
assertThat(requests).noneMatch(request -> request.startsWith("PUT"));
}
}
/*******************************************************************************
** Multi-node clusters keep (or get) one replica by default.
*******************************************************************************/
@Test
void multiNodeKeepsOneReplica() throws Exception
{
dataNodes = 3;
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config()))
{
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(1, 3, 0, false));
assertThat(requests).noneMatch(request -> request.startsWith("PUT"));
}
}
/*******************************************************************************
** An explicit indexReplicas wins and does not need cluster health.
*******************************************************************************/
@Test
void explicitReplicasOverrideAuto() throws Exception
{
settings = settingsBody("0", null);
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config().withIndexReplicas(2)))
{
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(2, null, 1, false));
assertThat(requests).doesNotContain("GET /_cluster/health");
assertThat(bodies.get(requests.indexOf("PUT /alias-v2-1/_settings"))).isEqualTo("{\"index\":{\"number_of_replicas\":2}}");
}
}
/*******************************************************************************
** An operator's auto_expand_replicas is left alone.
*******************************************************************************/
@Test
void autoExpandIsRespected() throws Exception
{
settings = settingsBody("1", "0-1");
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config()))
{
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(0, 1, 0, false));
assertThat(requests).noneMatch(request -> request.startsWith("PUT"));
}
}
/*******************************************************************************
** Failures are recorded, never thrown.
*******************************************************************************/
@Test
void failuresAreToleratedAndRecorded() throws Exception
{
putStatus = 403;
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config()))
{
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(0, 1, 0, true));
settingsStatus = 500;
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(0, 1, 0, true));
dataNodes = null;
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(null, null, 0, true));
assertThat(client.getReplicaStatus().failed()).isTrue();
}
}
/*******************************************************************************
** An unreachable cluster is a recorded failure too.
*******************************************************************************/
@Test
void unreachableIsRecorded() throws Exception
{
QuickSearchQBitConfig config = config();
server.stop(0);
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config.withConnectTimeoutMillis(500)))
{
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(null, null, 0, true));
}
}
/*******************************************************************************
** OpenSearch Serverless manages replicas itself: nothing is sent.
*******************************************************************************/
@Test
void serverlessIsNotManaged() throws Exception
{
QuickSearchQBitConfig config = config().withAuthMode(QuickSearchAuthMode.AWS_SIGV4).withAwsServiceName("aoss").withAwsRegion("us-east-1");
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config))
{
assertThat(client.reconcileReplicas()).isNull();
assertThat(client.getReplicaStatus()).isNull();
assertThat(requests).isEmpty();
}
}
}
@@ -29,6 +29,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.audits.AuditsMetaDataProvider;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QCollectingLogger;
import com.kingsrook.qqq.backend.core.logging.QLogger;
@@ -324,6 +325,45 @@ class BasepullIndexStepTest extends BaseQuickSearchTest
/*******************************************************************************
** Basepull bookkeeping on the index row (status, lastBasepullTime,
** documentCount, last run) writes no audit; an admin edit of the same row
** is still audited.
*******************************************************************************/
@Test
void testBasepullBookkeeping_isNotAudited_adminEditIs() throws QException
{
if(QContext.getQInstance().getTable(AuditsMetaDataProvider.TABLE_NAME_AUDIT) == null)
{
new AuditsMetaDataProvider().defineAll(QContext.getQInstance(), TEST_BACKEND_NAME, null);
}
insertTestEntities(3);
QRecord row = insertIndexRow(TEST_ENTITY_TABLE, true, null, 5);
when(mockClient.countDocumentsForTable(TEST_ENTITY_TABLE)).thenReturn(3L);
new BasepullIndexStep().run(new RunBackendStepInput(), new RunBackendStepOutput());
assertThat(queryAll(QuickSearchIndex.TABLE_NAME).get(0).getValueInteger("documentCount")).isEqualTo(3);
assertThat(indexRowEditAudits()).as("basepull bookkeeping is not audited").isZero();
new UpdateAction().execute(new UpdateInput().withTableName(QuickSearchIndex.TABLE_NAME)
.withRecord(new QRecord().withValue("id", row.getValue("id")).withValue("basepullIntervalMinutes", 15)));
assertThat(indexRowEditAudits()).as("an admin configuration edit is audited").isEqualTo(1);
}
/*******************************************************************************
** Audit rows recording an edit of a quick_search_index row.
*******************************************************************************/
private long indexRowEditAudits() throws QException
{
return (queryAll(AuditsMetaDataProvider.TABLE_NAME_AUDIT).stream()
.filter(audit -> audit.getValueString("message") != null && audit.getValueString("message").contains("Edited"))
.count());
}
/*******************************************************************************
** Test: no discovered tables, no quickSearchIndex rows - step completes
** without calling the OpenSearch client.