Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ccf082493
|
||
|
|
a099a1d1b5
|
||
|
|
e6f6b66b93 | ||
|
|
7719f63db2
|
||
|
|
923aec8e78 |
No files matched your search
+1
-12
@@ -22,7 +22,7 @@ workflows:
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*|hotfix\/.*)/
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
@@ -58,14 +58,3 @@ workflows:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
|
||||
publish_hotfix_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: hotfix
|
||||
filters:
|
||||
branches:
|
||||
only: [/hotfix\/.*/]
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
+2
-1
@@ -4,9 +4,10 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Requires QQQ 4.1.0-RC.4 or later (the developer panel contract, QRun-IO/qqq#1076).
|
||||
Requires QQQ 4.1.0-RC.6 or later (the spec-managed UI API extension contract).
|
||||
|
||||
### Added
|
||||
- Supported host status API (`api.QuickSearchStatusService.get()`), immutable status values, and `QuickSearchApi` home navigation constants (issue #50/F10). Permission-checked, bounded diagnostics refreshes share a short per-runtime cache and reuse saved validation counts. New spec-managed `GET /qqq/v1/qbit/quick-search/status` (`getQuickSearchStatus`) with committed OpenAPI fragment and strict HTTP/contract tests. RC.6 requires the default `/qbit/quick-search` prefix; `/quick-search` is not permitted by core. Existing routes and wire shapes are unchanged.
|
||||
- Record developer panel: `QuickSearchDeveloperPanelProvider` (id `quickSearch`) adds a "Quick Search" panel to the developer view of every record of an indexed table (`/app/{table}/{id}/dev` in the Next UI), for users who may read Quick Search Index. It shows the table's index row (enabled, status, record count, last basepull and full reindex, searchable fields), the latest index run, the record's indexed document compared field by field with the database (Inspect Search Document's comparison; needs `debugPermissionRules`), the record's failed index events, the Inspect Search Document and Reindex in Search actions the user may run, and links to the admin app and the index row. An unreachable OpenSearch shows a warning in the panel. Registered by `QuickSearchDeveloperPanelsMetaDataProducer`.
|
||||
|
||||
- **Data-driven configuration.** The searchable tables and fields are records: `quick_search_index` (the table registry, new columns `source` and `ownerEdited`) and the new `quick_search_index_field` (weight, role `DISPLAY`/`TITLE`/`SUBTITLE`/`BODY`/`KEYWORD`/`EXACT`, analyzer `PREFIX`/`WORD`/`ENGLISH`/`EXACT`, snippet and label flags). Indexing, search, the record-search provider and the diagnostics read the effective config from them (cached 30 s, dropped on change), so changes need no redeploy. Code (`@QuickSearchable`, `searchableTables`) only seeds them; rows an admin edited are never overwritten, and removals are remembered in the new `quick_search_config_tombstone`. Precedence and DDL: `docs/MIGRATION-1.1.md`, "Data-driven configuration".
|
||||
|
||||
@@ -288,3 +288,59 @@ See also the [QQQ adoption checklist](https://github.com/QRun-IO/qqq/blob/develo
|
||||
## License
|
||||
|
||||
Apache License, Version 2.0. See [LICENSE](LICENSE).
|
||||
|
||||
## Status API for hosts
|
||||
|
||||
Requires QQQ **4.1.0-RC.6**. Hosts should use the supported API rather than reach into
|
||||
`QuickSearchRuntime`, `QuickSearchOpenSearchClient`, diagnostic renderers, or qbit tables:
|
||||
|
||||
```java
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatus;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatusService;
|
||||
|
||||
// Runs in the host request's QContext, with the caller's QSession.
|
||||
QuickSearchStatus status = QuickSearchStatusService.get();
|
||||
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
|
||||
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
|
||||
required: like index validation, this is an administrator's aggregate view of the index.
|
||||
The committed contract is [quick-search.yaml](src/main/resources/openapi/qqq-v1/quick-search.yaml).
|
||||
|
||||
The default core namespace is intentional: RC.6 permits `/qbit/quick-search` but rejects
|
||||
`/quick-search`, which is not an established legacy prefix. There were no existing
|
||||
Quick Search UI API routes to preserve. Core source is unchanged.
|
||||
|
||||
- `state`: `OK`, `DEGRADED` (including paused indexing, backlog, incomplete diagnostics
|
||||
or a refresh timeout), `DOWN` (unavailable health or red health), or `DISABLED`
|
||||
(qbit absent or every persisted index disabled). Pausing indexing does not disable search.
|
||||
- `clusterReachable`, `clusterHealth`, `nodes`, `activeShards`, `unassignedShards` come
|
||||
from the existing diagnostics health operation. Null means unknown/not probed.
|
||||
- `indexes` contains up to 100 persisted index rows. `documentCount` and `sourceCount`
|
||||
come from the latest saved `IndexValidator` report, with `countsCheckedAt`. Without
|
||||
matching validation, only the last indexing run's document count is available and
|
||||
its observation time is unknown. These are **saved counts, not live counts**. A
|
||||
missing source count must not be displayed as zero or “in sync.”
|
||||
- `pending` includes `PENDING` and `AWAITING_REINDEX` failed events; `exhausted` counts
|
||||
failed events that gave up. At most 1000 backlog rows are read; `backlogTruncated`
|
||||
makes both counts lower bounds. `tablesTruncated` indicates incomplete index rows.
|
||||
These counters do not measure an external publisher's queue.
|
||||
- `lastSuccessfulSync` is the newest successful indexing watermark among the included
|
||||
tables (basepull is start-anchored), not proof that all tables are current.
|
||||
`lastError` is the newest available indexing/runtime search error or snapshot failure.
|
||||
- `checkedAt` is snapshot completion time. A per-runtime five-second cache bounds
|
||||
polling cost; each refresh uses `DiagnosticsRateLimiter`'s per-user `STATUS` key.
|
||||
Rate-limit refusal uses the existing diagnostics error (HTTP 400). A refresh waits
|
||||
at most two seconds and retains at most one in-flight worker, even if a backend is
|
||||
stuck. Runtime shutdown stops the worker. No source counts, record samples, full
|
||||
validation, or per-table OpenSearch calls run on this polling path.
|
||||
|
||||
Link to `status.homeHref()` instead of copying the Quick Search home. It includes the
|
||||
configured metadata prefix. `QuickSearchApi.APP_NAME` (`quickSearchAdmin`) and
|
||||
`QuickSearchApi.HOME_HREF` (`/app/quickSearchAdmin`) are available for default-name
|
||||
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).
|
||||
+11
-1
@@ -1,6 +1,16 @@
|
||||
# Session State
|
||||
|
||||
Updated: 2026-10-06
|
||||
Updated: 2026-10-13
|
||||
|
||||
## Status API worktree (2026-10-13, issue #50 / F10)
|
||||
|
||||
- Implemented `api.QuickSearchStatusService.get()`, immutable status values, default app/home constants and a spec-managed status endpoint. Admin permission is checked on every call; saved validation counts avoid source scans. Five-second per-runtime cache, two-second wait, one in-flight worker, existing diagnostics rate limiter, bounded table/backlog reads.
|
||||
- Endpoint uses core's permitted `/qqq/v1/qbit/quick-search/status` namespace (`getQuickSearchStatus`). Requested `/quick-search` is rejected by RC.6's established-prefix allowlist. Core and host clones are read-only; no edits there. No previous Quick Search HTTP operations or Next UI callers found.
|
||||
- POM targets released-coordinate `4.1.0-RC.6`; local verification uses only `-Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT`.
|
||||
- Host file/line replacement mapping: `docs/STATUS-API-HOST-MIGRATION.md`. Contract: `src/main/resources/openapi/qqq-v1/quick-search.yaml`.
|
||||
- Verification: 468 unit tests (including strict HTTP, contract, bounds, cache isolation, permissions and timeout recovery); JaCoCo passes. All 41 Docker integration tests skip because Docker is unavailable. Final command: `./mvnw.cmd -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT`.
|
||||
- Standalone repository Checkstyle is not green: HEAD has 471 main-source violations; this tree has 438, with zero newly introduced violations. New production files and both new test files pass their style checks. Existing header/import/style cleanup is outside the status API change.
|
||||
- No commit, push, merge or deployment authorized. Second-brain vault is outside writable roots; durable notes are retained here for later vault synchronization.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Quick Search host migration (kof22 #164)
|
||||
|
||||
Issue #50 / architecture finding F10. Paths below are under
|
||||
`Website-Backend/src/main/java/com/kof22/website/metadata/adminhome/`;
|
||||
line numbers refer to the local clone inspected on 2026-10-13. No host files were changed.
|
||||
|
||||
| Existing host code | Replacement |
|
||||
| --- | --- |
|
||||
| `LiveAdminHomeProbes.java:340` `searchConfigured()` | Use `QuickSearchApi.APP_NAME` for the default-name app lookup; for an authorized snapshot use `QuickSearchStatusService.get()` and its state/homeHref. `DISABLED` also includes all indexes disabled, so it is not exactly “qbit absent.” |
|
||||
| `LiveAdminHomeProbes.java:351` `searchCluster()` | One `QuickSearchStatusService.get()`; map `clusterReachable`, `clusterHealth`, `nodes`, `activeShards`, `unassignedShards`. Remove direct cluster HTTP/auth/CA handling. |
|
||||
| `LiveAdminHomeProbes.java:386` `searchDocumentCounts(...)` | Reuse the same snapshot's `indexes()` and each row's `tableName()` / `documentCount()` / `sourceCount()` / `countsCheckedAt()`. Remove `QuickSearchOpenSearchClient` access and relabel counts as saved diagnostics, not live. Preserve null as unknown. |
|
||||
| `QuickSearchHomeWidgets.java:118` `widgets()` | Stop adding the host's duplicate Quick Search home widgets. Link to `status.homeHref()` (or default `QuickSearchApi.HOME_HREF`). Keep the qbit-provided home/diagnostics widget. |
|
||||
| `QuickSearchHomeWidgets.java:133` `indexFacts()`, `:160` `summary()` and `:196` `syncTile()` | For a compact host overview, use the same `QuickSearchStatusService.get()` value: `state`, `indexes`, `pending`, `exhausted`, `lastSuccessfulSync`, `lastError`. Remove SQL aggregates and live source counts; use saved count timestamps and truncation flags. |
|
||||
| `QuickSearchHomeWidgets.java:267` `runs()`, `:315` `failedEvents()` | Replace duplicate detailed views with the home link; drill down using the qbit's own Index Run / Failed Event tables from that home. The summary API intentionally does not export detailed histories. |
|
||||
| `AdminOperationsWidgets.java:209` `quickSearch()` | Render `status.indexes()` for the compact admin overview, and use `status.homeHref()` for its footer link. Remove qbit-table SQL and source-table counts. |
|
||||
| `AdminOperationsWidgets.java:343` `searchFooter()` | Use `clusterHealth`, `pending`, `exhausted`, `lastSuccessfulSync`, `lastError`, and `checkedAt` from the same snapshot. Detailed recent-run information belongs in the qbit home. |
|
||||
|
||||
Call with the existing request's `QContext`/`QSession`; do not elevate a host user's
|
||||
session to bypass `adminPermissionRules`. Cache hits still check permission.
|
||||
|
||||
## UI contract
|
||||
|
||||
| Method | Path | operationId |
|
||||
| --- | --- | --- |
|
||||
| GET | `/qqq/v1/qbit/quick-search/status` | `getQuickSearchStatus` |
|
||||
|
||||
Extension: `quick-search`, version `1.0.0`; core dependency: `4.1.0-RC.6`.
|
||||
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.
|
||||
|
||||
Wire changes against develop: one additive endpoint and its schemas; no existing
|
||||
method, path, request, or response changed. Next UI `src/` contains no calls to a
|
||||
Quick Search-specific endpoint today; its core `/qqq/v1/search` integration is unchanged.
|
||||
Vendor `src/main/resources/openapi/qqq-v1/quick-search.yaml` for clients.
|
||||
|
||||
Regenerate the fragment with:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -B test -Dtest=QuickSearchApiContractTest -Dquicksearch.openapi.update=true -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT
|
||||
```
|
||||
|
||||
The local verification override is never the committed dependency version.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
`./mvnw.cmd -B verify -Dqqq.snapshot.version=4.1.0-ARCHRC6-SNAPSHOT` passes
|
||||
468 unit tests and JaCoCo, including strict HTTP server startup, permission checks,
|
||||
OpenAPI contract consistency, cached/disabled/degraded/down states, timeout recovery,
|
||||
bounded backlog/table lists and prefixed QInstance isolation. All 41 Docker integration
|
||||
tests skip locally because Docker is unavailable.
|
||||
|
||||
The separate Checkstyle check is **not green**: the existing checked-in rules report
|
||||
471 main-source violations at HEAD and 438 in this worktree, with zero new violations.
|
||||
New source files and the two new tests pass those rules. No repository-wide header
|
||||
rewrite or unrelated style cleanup was included.
|
||||
@@ -1,5 +1,14 @@
|
||||
# TODO
|
||||
|
||||
## Architecture status API (#50 / F10)
|
||||
|
||||
- [x] Supported Java status API, bounded cache/rate limiting, administrator authorization, home navigation, spec endpoint and committed fragment.
|
||||
- [x] Contract and strict HTTP tests; host migration mapping for kof22#164.
|
||||
- [ ] kof22#164: replace internal probes/duplicate home using `docs/STATUS-API-HOST-MIGRATION.md`.
|
||||
- [ ] Next UI: vendor the additive Quick Search status fragment when adopting RC.6.
|
||||
- [ ] Run Docker integration tests in CI; local Docker is unavailable.
|
||||
- [ ] Resolve the pre-existing repository Checkstyle baseline (438 remaining main-source violations, down from 471 at HEAD); status API adds none.
|
||||
|
||||
## Before tagging 1.0.0
|
||||
|
||||
- [ ] qqq-all and Voyage adopt `1.0.0-RC.2` (items below) and report back.
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<qbit.guardrails.enforce>true</qbit.guardrails.enforce>
|
||||
<qbit.guardrails.strictUiApiRoutes>true</qbit.guardrails.strictUiApiRoutes>
|
||||
<revision>1.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
@@ -30,7 +32,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.0.0</version>
|
||||
<version>2.1.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -70,6 +72,11 @@
|
||||
</scm>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
@@ -274,7 +281,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -25,6 +25,14 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.quicksearch.actions.QuickSearchRecordSearchProvider;
|
||||
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.listeners.QuickSearchRecordChangeListener;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchAdminAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchDiagnosticsProcessMetaDataHelper;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchProcessMetaDataHelper;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
@@ -38,13 +46,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import com.kingsrook.qbits.quicksearch.actions.QuickSearchRecordSearchProvider;
|
||||
import com.kingsrook.qbits.quicksearch.annotations.QuickSearchField;
|
||||
import com.kingsrook.qbits.quicksearch.annotations.QuickSearchable;
|
||||
import com.kingsrook.qbits.quicksearch.listeners.QuickSearchRecordChangeListener;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchAdminAppMetaDataProducer;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchDiagnosticsProcessMetaDataHelper;
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchProcessMetaDataHelper;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@@ -262,6 +264,9 @@ public class QuickSearchQBitProducer implements QBitMetaDataProducer<QuickSearch
|
||||
|
||||
qInstance.withRuntimeService(new QCodeReference(QuickSearchRuntimeService.class));
|
||||
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance)
|
||||
.withExtension(QuickSearchApi.extension());
|
||||
|
||||
addRecordDiagnosticActions(output, qInstance, discoveredTables);
|
||||
|
||||
if(Boolean.TRUE.equals(qBitConfig.getServeCoreRecordSearch()))
|
||||
|
||||
@@ -26,6 +26,13 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchConfigStore;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticEventRecorder;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsRateLimiter;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.StatusCache;
|
||||
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.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -38,12 +45,6 @@ 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.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchConfigStore;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticEventRecorder;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsRateLimiter;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient;
|
||||
import com.kingsrook.qbits.quicksearch.publisher.IndexEventPublisher;
|
||||
import com.kingsrook.qbits.quicksearch.publisher.SynchronousIndexEventPublisher;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@@ -71,6 +72,8 @@ public class QuickSearchRuntime
|
||||
|
||||
public static final long RECORD_SEARCH_BACKOFF_MILLIS = 30_000L;
|
||||
|
||||
private final StatusCache statusCache = new StatusCache();
|
||||
|
||||
private final QuickSearchQBitConfig config;
|
||||
|
||||
private List<QuickSearchableTableConfig> codeTables = new ArrayList<>();
|
||||
@@ -99,6 +102,16 @@ public class QuickSearchRuntime
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance-owned bounded administrative status cache.
|
||||
*******************************************************************************/
|
||||
public StatusCache getStatusCache()
|
||||
{
|
||||
return (statusCache);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A cached enabled flag with the time it was read.
|
||||
*******************************************************************************/
|
||||
@@ -728,6 +741,7 @@ public class QuickSearchRuntime
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
statusCache.close();
|
||||
|
||||
if(publisher != null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.quicksearch.metadata.QuickSearchAdminAppMetaDataProducer;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supported host navigation and UI API extension identity.
|
||||
*******************************************************************************/
|
||||
public final class QuickSearchApi
|
||||
{
|
||||
public static final String APP_NAME = QuickSearchAdminAppMetaDataProducer.APP_NAME;
|
||||
public static final String HOME_HREF = "/app/" + APP_NAME;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QuickSearchApi()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The spec registration shared by production and contract generation.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("quick-search").withSemanticVersion("1.0.0")
|
||||
.withDescription("Quick Search administrative status").withSpec(QuickSearchStatusSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
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.
|
||||
*******************************************************************************/
|
||||
public record 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) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Published wire schema for the same immutable value used by Java hosts.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new QuickSearchStatusSpec().defineComponentSchemas().get("QuickSearchStatusV1"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Protect the snapshot from mutations by callers.
|
||||
*******************************************************************************/
|
||||
public QuickSearchStatus
|
||||
{
|
||||
indexes = List.copyOf(indexes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Overall availability; DEGRADED also covers incomplete measurements.
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
OK, DEGRADED, DOWN, DISABLED
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A persisted index row with counts from the latest saved validation, when
|
||||
** available. Otherwise documentCount is the last indexing run's count and
|
||||
** sourceCount/countsCheckedAt are unknown. Sync time is a successful watermark.
|
||||
*******************************************************************************/
|
||||
public record IndexRow(String tableName, Boolean enabled, String status, Long documentCount, Long sourceCount, Instant countsCheckedAt,
|
||||
Instant lastSuccessfulSync, String lastError, Integer realTimeErrorCount) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Published per-table schema.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new QuickSearchStatusSpec().defineComponentSchemas().get("QuickSearchIndexStatusV1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.StatusDiagnostics;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Supported entry point for hosts. Uses the current QContext and checks the
|
||||
** Quick Search admin app permission on EVERY call, including cache hits.
|
||||
*******************************************************************************/
|
||||
public final class QuickSearchStatusService
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QuickSearchStatusService()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Return a briefly cached snapshot, waiting at most two seconds for a refresh.
|
||||
** Call with an authenticated QSession (or an explicitly trusted system session).
|
||||
*******************************************************************************/
|
||||
public static QuickSearchStatus get() throws QException
|
||||
{
|
||||
if(QContext.getQSession() == null)
|
||||
{
|
||||
throw (new QPermissionDeniedException("A session is required for Quick Search status."));
|
||||
}
|
||||
QuickSearchRuntime runtime = QuickSearchRuntime.get();
|
||||
if(runtime == null)
|
||||
{
|
||||
return (StatusDiagnostics.unavailable(QuickSearchStatus.State.DISABLED, null, QuickSearchApi.HOME_HREF));
|
||||
}
|
||||
String appName = runtime.getConfig().applyPrefix(QuickSearchApi.APP_NAME);
|
||||
if(QContext.getQInstance().getApp(appName) == null || !PermissionsHelper.hasAppPermission(new QueryInput(), appName))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Quick Search admin permission is required."));
|
||||
}
|
||||
return (runtime.getStatusCache().get(runtime));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* 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.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Method;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Administrative status, available through the spec-managed UI API only.
|
||||
*******************************************************************************/
|
||||
public class QuickSearchStatusSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe the supported operation.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath("/qbit/quick-search/status")
|
||||
.withShortSummary("Get Quick Search status")
|
||||
.withLongDescription("Requires Quick Search admin permission. Five-second instance cache; two-second refresh wait. Counts are saved diagnostics, not live source scans. Unknown values are null. Backlog counts are lower bounds when backlogTruncated is true."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable generated-client operation name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected void customizeMethod(Method method)
|
||||
{
|
||||
method.setOperationId("getQuickSearchStatus");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No request parameters.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned response component.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return ("QuickSearchStatusV1");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use exactly the same authorized service as in-process hosts.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (QuickSearchStatusService.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard framework errors, including the diagnostics rate limit.
|
||||
*******************************************************************************/
|
||||
@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")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit schemas make nullability, bounds and measurement age discoverable.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
Map<String, Schema> status = new TreeMap<>();
|
||||
status.put("state", field(Type.STRING, "Overall availability; incomplete diagnostics are DEGRADED.", false)
|
||||
.withEnumValues(List.of("OK", "DEGRADED", "DOWN", "DISABLED")));
|
||||
status.put("clusterReachable", field(Type.BOOLEAN, "Whether OpenSearch answered; null when not probed.", true));
|
||||
status.put("clusterHealth", field(Type.STRING, "OpenSearch health color; null when unknown.", true));
|
||||
for(String name : List.of("nodes", "activeShards", "unassignedShards"))
|
||||
{
|
||||
status.put(name, field(Type.INTEGER, "Cluster health counter; null when unavailable.", 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."));
|
||||
status.put("pending", field(Type.INTEGER, "Pending and awaiting-reindex failed events, from at most 1000 backlog rows.", true));
|
||||
status.put("exhausted", field(Type.INTEGER, "Exhausted failed events, from the same bounded backlog read.", true));
|
||||
status.put("backlogTruncated", field(Type.BOOLEAN, "Counts are lower bounds when more than 1000 backlog rows exist.", false));
|
||||
status.put("tablesTruncated", field(Type.BOOLEAN, "More than 100 index rows exist.", false));
|
||||
status.put("lastSuccessfulSync", timestamp("Newest successful indexing watermark; not a guarantee all tables are current."));
|
||||
status.put("lastError", field(Type.STRING, "Last known indexing/search error or snapshot failure; null when absent.", true));
|
||||
status.put("checkedAt", timestamp("Snapshot completion time; remains unchanged on cache hits.").withNullable(false));
|
||||
status.put("homeHref", field(Type.STRING, "Quick Search home link, including the configured metadata prefix.", false));
|
||||
Map<String, Schema> row = new TreeMap<>();
|
||||
row.put("tableName", field(Type.STRING, "Indexed source table metadata name.", false));
|
||||
row.put("enabled", field(Type.BOOLEAN, "Whether this index is enabled.", false));
|
||||
row.put("status", field(Type.STRING, "Persisted indexing state, such as ACTIVE or NEEDS_REINDEX.", true));
|
||||
row.put("documentCount", field(Type.INTEGER, "Saved validation document count, else last indexing run count; not live.", true).withFormat("int64"));
|
||||
row.put("sourceCount", field(Type.INTEGER, "Source count from saved IndexValidator report; null without validation.", true).withFormat("int64"));
|
||||
row.put("countsCheckedAt", timestamp("Saved validation completion; null when validation counts are unavailable."));
|
||||
row.put("lastSuccessfulSync", timestamp("Newest successful basepull/full-reindex/reconcile watermark."));
|
||||
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)))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A response object always includes its properties, including nulls.
|
||||
*******************************************************************************/
|
||||
private static Schema object(Map<String, Schema> properties)
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withProperties(new TreeMap<>(properties)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A scalar with explicit unknown semantics.
|
||||
*******************************************************************************/
|
||||
private static Schema field(Type type, String description, Boolean nullable)
|
||||
{
|
||||
return (new Schema().withType(type).withDescription(description).withNullable(nullable));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ISO-8601 instant.
|
||||
*******************************************************************************/
|
||||
private static Schema timestamp(String description)
|
||||
{
|
||||
return (field(Type.STRING, description, true).withFormat("date-time"));
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,20 @@ package com.kingsrook.qbits.quicksearch.diagnostics;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchDiagnosticEvent;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchValidationRun;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchDiagnosticEvent;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@@ -45,6 +52,26 @@ public final class DiagnosticsSupport
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Latest saved validation, shared by the home widget and host status API.
|
||||
** This does not run validation or scan source tables.
|
||||
*******************************************************************************/
|
||||
public static QRecord latestValidation(QuickSearchQBitConfig config) throws QException
|
||||
{
|
||||
String tableName = config.applyTablePrefix(QuickSearchValidationRun.TABLE_NAME);
|
||||
if(QContext.getQInstance().getTable(tableName) == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
var input = new QueryInput(tableName)
|
||||
.withFilter(new QQueryFilter()
|
||||
.withOrderBy(new QFilterOrderBy("id", false)).withLimit(1));
|
||||
var rows = new QueryAction().execute(input).getRecords();
|
||||
return (rows == null || rows.isEmpty() ? null : rows.get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class - no instances.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -28,6 +28,18 @@ import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsHtml.Level;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFailedEvent;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchValidationRun;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchDiagnosticsOperations;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchDiagnosticsOperations.RawResponse;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient;
|
||||
import com.kingsrook.qbits.quicksearch.processes.AbstractIndexingStep;
|
||||
import com.kingsrook.qbits.quicksearch.processes.IndexingUtils;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
@@ -46,18 +58,6 @@ 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.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsHtml.Level;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFailedEvent;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchValidationRun;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchDiagnosticsOperations;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchDiagnosticsOperations.RawResponse;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient;
|
||||
import com.kingsrook.qbits.quicksearch.processes.AbstractIndexingStep;
|
||||
import com.kingsrook.qbits.quicksearch.processes.IndexingUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
@@ -293,6 +293,16 @@ public class IndexValidator
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared cluster health interpretation for validation and the host status API.
|
||||
*******************************************************************************/
|
||||
public static Level healthLevel(String status)
|
||||
{
|
||||
return (healthFinding("Health", status).level());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The live mapping against the mapping this version creates.
|
||||
*******************************************************************************/
|
||||
|
||||
+8
-12
@@ -19,6 +19,12 @@ package com.kingsrook.qbits.quicksearch.diagnostics;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchStatus;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsHtml.Level;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchDiagnosticEvent;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchValidationRun;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
@@ -34,12 +40,6 @@ import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.RawHTML;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchStatus;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsHtml.Level;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchDiagnosticEvent;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchValidationRun;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -211,18 +211,14 @@ public class QuickSearchDiagnosticsWidgetRenderer extends AbstractWidgetRenderer
|
||||
|
||||
try
|
||||
{
|
||||
QueryInput queryInput = new QueryInput();
|
||||
queryInput.setTableName(tableName);
|
||||
queryInput.setFilter(new QQueryFilter().withOrderBy(new QFilterOrderBy("id", false)).withLimit(1));
|
||||
List<QRecord> rows = CollectionUtils.nonNullList(new QueryAction().execute(queryInput).getRecords());
|
||||
if(rows.isEmpty())
|
||||
QRecord row = DiagnosticsSupport.latestValidation(config);
|
||||
if(row == null)
|
||||
{
|
||||
html.paragraph("None yet. Run Validate Search Index from Quick Search Index (Actions)"
|
||||
+ (config.getValidationCronExpression() == null ? "." : "; it is also scheduled (" + config.getValidationCronExpression() + ")."));
|
||||
return;
|
||||
}
|
||||
|
||||
QRecord row = rows.get(0);
|
||||
String status = row.getValueString("status");
|
||||
Level level = QuickSearchValidationRun.STATUS_PASS.equals(status) ? Level.OK : (QuickSearchValidationRun.STATUS_WARN.equals(status) ? Level.WARN : Level.FAIL);
|
||||
html.raw("<p>" + DiagnosticsHtml.badge(level) + " <a href=\"/app/" + DiagnosticsHtml.escape(tableName) + "/" + DiagnosticsHtml.escape(row.getValueString("id")) + "\">"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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.diagnostics;
|
||||
|
||||
|
||||
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 com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchApi;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatus;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Instance-owned five-second cache with one in-flight refresh. A stuck backend
|
||||
** cannot grow a queue or create more workers. Runtime shutdown stops the worker.
|
||||
*******************************************************************************/
|
||||
public class StatusCache implements AutoCloseable
|
||||
{
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor(runnable ->
|
||||
{
|
||||
Thread thread = new Thread(runnable, "quick-search-status");
|
||||
thread.setDaemon(true);
|
||||
return (thread);
|
||||
});
|
||||
private Future<QuickSearchStatus> refresh;
|
||||
private QuickSearchStatus cached;
|
||||
private long expiresAt;
|
||||
private long refreshStartedAt;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
{
|
||||
if(cached != null && System.nanoTime() < expiresAt)
|
||||
{
|
||||
return (cached);
|
||||
}
|
||||
String homeHref = "/app/" + runtime.getConfig().applyPrefix(QuickSearchApi.APP_NAME);
|
||||
// A refresh that finished while nobody was polling must not resurrect an old snapshot.
|
||||
if(refresh != null && refresh.isDone() && System.nanoTime() - refreshStartedAt >= TimeUnit.SECONDS.toNanos(5))
|
||||
{
|
||||
refresh = null;
|
||||
}
|
||||
if(refresh == null)
|
||||
{
|
||||
DiagnosticsSupport.checkRateLimit(runtime, "STATUS");
|
||||
QInstance instance = QContext.getQInstance();
|
||||
refreshStartedAt = System.nanoTime();
|
||||
refresh = executor.submit(() ->
|
||||
{
|
||||
QContext.init(instance, new QSystemUserSession(instance));
|
||||
try
|
||||
{
|
||||
return (StatusDiagnostics.read(runtime));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
try
|
||||
{
|
||||
cached = refresh.get(2, TimeUnit.SECONDS);
|
||||
refresh = null;
|
||||
}
|
||||
catch(TimeoutException e)
|
||||
{
|
||||
// Leave the one worker in flight; never pile up abandoned requests.
|
||||
cached = StatusDiagnostics.unavailable(QuickSearchStatus.State.DEGRADED, "Status refresh timed out; measurements are unknown.", homeHref);
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw (new QException("Status refresh interrupted", e));
|
||||
}
|
||||
catch(ExecutionException e)
|
||||
{
|
||||
refresh = null;
|
||||
cached = StatusDiagnostics.unavailable(QuickSearchStatus.State.DEGRADED, "Status diagnostics could not be read.", homeHref);
|
||||
}
|
||||
expiresAt = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
return (cached);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stop the bounded worker when its runtime closes.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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.diagnostics;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchApi;
|
||||
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.model.QuickSearchFailedEvent;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchDiagnosticsOperations;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cheap projection of existing diagnostics: one health request, bounded metadata
|
||||
** reads, saved IndexValidator counts. No source scans or per-table remote calls.
|
||||
*******************************************************************************/
|
||||
public final class StatusDiagnostics
|
||||
{
|
||||
public static final int MAX_TABLES = 100;
|
||||
public static final int MAX_BACKLOG = 1000;
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private StatusDiagnostics()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read as the trusted diagnostics worker after the caller's admin check.
|
||||
*******************************************************************************/
|
||||
public static QuickSearchStatus read(QuickSearchRuntime runtime) throws Exception
|
||||
{
|
||||
String homeHref = "/app/" + runtime.getConfig().applyPrefix(QuickSearchApi.APP_NAME);
|
||||
List<QRecord> rows = query(runtime.getConfig().getQuickSearchIndexTableName(), new QQueryFilter()
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(MAX_TABLES + 1));
|
||||
boolean truncated = rows.size() > MAX_TABLES;
|
||||
QRecord validation = DiagnosticsSupport.latestValidation(runtime.getConfig());
|
||||
String json = validation == null ? null : validation.getValueString("reportJson");
|
||||
JsonNode report = json == null || json.length() > 1_000_000 ? MAPPER.createObjectNode() : MAPPER.readTree(json);
|
||||
Instant countsAt = validation == null ? null : validation.getValueInstant("endTime");
|
||||
List<IndexRow> indexes = new ArrayList<>();
|
||||
Instant lastSync = null;
|
||||
String lastError = runtime.getLastRecordSearchFailureMessage();
|
||||
Instant lastErrorAt = runtime.getLastRecordSearchFailureTime();
|
||||
boolean degraded = truncated || runtime.isRecordSearchBackingOff() || (rows.isEmpty() && !runtime.getCodeTables().isEmpty());
|
||||
for(QRecord row : rows.subList(0, Math.min(rows.size(), MAX_TABLES)))
|
||||
{
|
||||
String tableName = row.getValueString("tableName");
|
||||
Long documents = row.getValueLong("documentCount");
|
||||
Long source = null;
|
||||
Instant measuredAt = null;
|
||||
for(JsonNode stats : report.path("tableStats"))
|
||||
{
|
||||
if(tableName.equals(stats.path("tableName").asText()))
|
||||
{
|
||||
documents = stats.path("indexCount").isNumber() ? stats.path("indexCount").longValue() : null;
|
||||
source = stats.path("databaseCount").isNumber() ? stats.path("databaseCount").longValue() : null;
|
||||
measuredAt = countsAt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Instant sync = latest(row.getValueInstant("lastBasepullTime"), latest(row.getValueInstant("lastFullReindexTime"), row.getValueInstant("lastReconcileTime")));
|
||||
lastSync = latest(lastSync, sync);
|
||||
String error = row.getValueString("lastErrorMessage");
|
||||
if(error != null && !error.isBlank() && (lastErrorAt == null || (row.getValueInstant("modifyDate") != null && row.getValueInstant("modifyDate").isAfter(lastErrorAt))))
|
||||
{
|
||||
lastError = error;
|
||||
lastErrorAt = row.getValueInstant("modifyDate");
|
||||
}
|
||||
boolean enabled = !Boolean.FALSE.equals(row.getValueBoolean("enabled"));
|
||||
Integer realTimeErrors = row.getValueInteger("realTimeErrorCount");
|
||||
degraded |= enabled && (!"ACTIVE".equals(row.getValueString("status")) || "FAILED".equals(row.getValueString("lastRunStatus"))
|
||||
|| (realTimeErrors != null && realTimeErrors > 0) || (source != null && documents != null && !source.equals(documents)));
|
||||
indexes.add(new IndexRow(tableName, enabled, row.getValueString("status"), documents, source, measuredAt, sync, error, realTimeErrors));
|
||||
}
|
||||
List<QRecord> backlog = query(runtime.getConfig().applyTablePrefix(QuickSearchFailedEvent.TABLE_NAME),
|
||||
new QQueryFilter(new QFilterCriteria("status", QCriteriaOperator.IN, List.of("PENDING", "EXHAUSTED", "AWAITING_REINDEX")))
|
||||
.withOrderBy(new QFilterOrderBy("id")).withLimit(MAX_BACKLOG + 1));
|
||||
int pending = 0;
|
||||
int exhausted = 0;
|
||||
for(QRecord row : backlog.subList(0, Math.min(backlog.size(), MAX_BACKLOG)))
|
||||
{
|
||||
if("EXHAUSTED".equals(row.getValueString("status")))
|
||||
{
|
||||
exhausted++;
|
||||
}
|
||||
else
|
||||
{
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
boolean paused = runtime.isIndexingPaused();
|
||||
degraded |= paused || pending > 0 || exhausted > 0;
|
||||
Boolean reachable = null;
|
||||
String health = null;
|
||||
Integer nodes = null;
|
||||
Integer active = null;
|
||||
Integer unassigned = null;
|
||||
State state = degraded ? State.DEGRADED : State.OK;
|
||||
if(!truncated && !indexes.isEmpty() && indexes.stream().noneMatch(row -> Boolean.TRUE.equals(row.enabled())))
|
||||
{
|
||||
state = State.DISABLED;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = new QuickSearchDiagnosticsOperations(runtime.getClient()).clusterHealth();
|
||||
reachable = true;
|
||||
QuickSearchDiagnosticsOperations.requireOk(response, "Cluster health unavailable");
|
||||
JsonNode body = response.body();
|
||||
health = body.path("status").asText("unknown");
|
||||
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())
|
||||
{
|
||||
state = "red".equals(health) ? State.DOWN : State.DEGRADED;
|
||||
}
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
if(reachable == null)
|
||||
{
|
||||
reachable = false;
|
||||
}
|
||||
state = State.DOWN;
|
||||
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,
|
||||
truncated, lastSync, lastError, Instant.now(), homeHref));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unknown measurements for disabled or failed snapshots.
|
||||
*******************************************************************************/
|
||||
public static QuickSearchStatus unavailable(State state, String error, String homeHref)
|
||||
{
|
||||
return (new QuickSearchStatus(state, null, null, null, null, null, null, List.of(), null, null, false, false, null, error, Instant.now(), homeHref));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounded metadata queries; failures propagate rather than looking healthy.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String tableName, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (CollectionUtils.nonNullList(new QueryAction().execute(new QueryInput(tableName).withFilter(filter)).getRecords()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve unknown counters from incomplete cluster responses.
|
||||
*******************************************************************************/
|
||||
private static Integer number(JsonNode body, String field)
|
||||
{
|
||||
return (body.path(field).isNumber() ? body.path(field).intValue() : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Newest non-null timestamp.
|
||||
*******************************************************************************/
|
||||
private static Instant latest(Instant first, Instant second)
|
||||
{
|
||||
return (first == null ? second : second == null || first.isAfter(second) ? first : second);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
components:
|
||||
schemas:
|
||||
QuickSearchErrorV1:
|
||||
properties:
|
||||
error:
|
||||
description: "Framework error message."
|
||||
nullable: false
|
||||
type: "string"
|
||||
type: "object"
|
||||
QuickSearchIndexStatusV1:
|
||||
properties:
|
||||
countsCheckedAt:
|
||||
description: "Saved validation completion; null when validation counts are\
|
||||
\ unavailable."
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
type: "string"
|
||||
documentCount:
|
||||
description: "Saved validation document count, else last indexing run count;\
|
||||
\ not live."
|
||||
format: "int64"
|
||||
nullable: true
|
||||
type: "integer"
|
||||
enabled:
|
||||
description: "Whether this index is enabled."
|
||||
nullable: false
|
||||
type: "boolean"
|
||||
lastError:
|
||||
description: "Persisted indexing error; null when absent."
|
||||
nullable: true
|
||||
type: "string"
|
||||
lastSuccessfulSync:
|
||||
description: "Newest successful basepull/full-reindex/reconcile watermark."
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
type: "string"
|
||||
realTimeErrorCount:
|
||||
description: "Persisted real-time indexing error count."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
sourceCount:
|
||||
description: "Source count from saved IndexValidator report; null without\
|
||||
\ validation."
|
||||
format: "int64"
|
||||
nullable: true
|
||||
type: "integer"
|
||||
status:
|
||||
description: "Persisted indexing state, such as ACTIVE or NEEDS_REINDEX."
|
||||
nullable: true
|
||||
type: "string"
|
||||
tableName:
|
||||
description: "Indexed source table metadata name."
|
||||
nullable: false
|
||||
type: "string"
|
||||
type: "object"
|
||||
QuickSearchStatusV1:
|
||||
properties:
|
||||
activeShards:
|
||||
description: "Cluster health counter; null when unavailable."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
backlogTruncated:
|
||||
description: "Counts are lower bounds when more than 1000 backlog rows exist."
|
||||
nullable: false
|
||||
type: "boolean"
|
||||
checkedAt:
|
||||
description: "Snapshot completion time; remains unchanged on cache hits."
|
||||
format: "date-time"
|
||||
nullable: false
|
||||
type: "string"
|
||||
clusterHealth:
|
||||
description: "OpenSearch health color; null when unknown."
|
||||
nullable: true
|
||||
type: "string"
|
||||
clusterReachable:
|
||||
description: "Whether OpenSearch answered; null when not probed."
|
||||
nullable: true
|
||||
type: "boolean"
|
||||
exhausted:
|
||||
description: "Exhausted failed events, from the same bounded backlog read."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
homeHref:
|
||||
description: "Quick Search home link, including the configured metadata\
|
||||
\ prefix."
|
||||
nullable: false
|
||||
type: "string"
|
||||
indexes:
|
||||
description: "At most 100 persisted index rows, ordered by id."
|
||||
items:
|
||||
$ref: "#/components/schemas/QuickSearchIndexStatusV1"
|
||||
type: "array"
|
||||
indexingPaused:
|
||||
description: "Indexing is paused; search can still serve."
|
||||
nullable: true
|
||||
type: "boolean"
|
||||
lastError:
|
||||
description: "Last known indexing/search error or snapshot failure; null\
|
||||
\ when absent."
|
||||
nullable: true
|
||||
type: "string"
|
||||
lastSuccessfulSync:
|
||||
description: "Newest successful indexing watermark; not a guarantee all\
|
||||
\ tables are current."
|
||||
format: "date-time"
|
||||
nullable: true
|
||||
type: "string"
|
||||
nodes:
|
||||
description: "Cluster health counter; null when unavailable."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
pending:
|
||||
description: "Pending and awaiting-reindex failed events, from at most 1000\
|
||||
\ backlog rows."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
state:
|
||||
description: "Overall availability; incomplete diagnostics are DEGRADED."
|
||||
enum:
|
||||
- "OK"
|
||||
- "DEGRADED"
|
||||
- "DOWN"
|
||||
- "DISABLED"
|
||||
nullable: false
|
||||
type: "string"
|
||||
tablesTruncated:
|
||||
description: "More than 100 index rows exist."
|
||||
nullable: false
|
||||
type: "boolean"
|
||||
unassignedShards:
|
||||
description: "Cluster health counter; null when unavailable."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "Quick Search administrative status"
|
||||
title: "QQQ Middleware API extension: quick-search"
|
||||
version: "1.0.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/qbit/quick-search/status:
|
||||
get:
|
||||
description: "Requires Quick Search admin permission. Five-second instance cache;\
|
||||
\ two-second refresh wait. Counts are saved diagnostics, not live source scans.\
|
||||
\ Unknown values are null. Backlog counts are lower bounds when backlogTruncated\
|
||||
\ is true."
|
||||
operationId: "getQuickSearchStatus"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
description: "Diagnostic rate limit exceeded"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchErrorV1"
|
||||
description: "Quick Search admin permission required"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QuickSearchStatusV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Get Quick Search status"
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchApi;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The committed contract is generated from exactly the registered specs.
|
||||
*******************************************************************************/
|
||||
class QuickSearchApiContractTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Opt-in regeneration follows core and collaboration's contract workflow.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
{
|
||||
var version = new MiddlewareVersionV1();
|
||||
var extension = QuickSearchApi.extension();
|
||||
String generated = UiApiContract.generateExtensionYaml(version, extension);
|
||||
Path path = Path.of("src/main/resources/openapi/qqq-v1/quick-search.yaml");
|
||||
if(Boolean.getBoolean("quicksearch.openapi.update"))
|
||||
{
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, generated);
|
||||
}
|
||||
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.getPaths().keySet()).containsExactly("/qqq/v1/qbit/quick-search/status");
|
||||
assertThat(extension.getSpecs()).hasSize(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchApi;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatus;
|
||||
import com.kingsrook.qbits.quicksearch.api.QuickSearchStatusService;
|
||||
import com.kingsrook.qbits.quicksearch.diagnostics.StatusDiagnostics;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchFailedEvent;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchValidationRun;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinImplementation;
|
||||
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 static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real bounded diagnostics over a memory backend and a loopback health endpoint.
|
||||
*******************************************************************************/
|
||||
class QuickSearchStatusTest
|
||||
{
|
||||
private HttpServer healthServer;
|
||||
private QuickSearchQBitConfig config;
|
||||
private QInstance instance;
|
||||
private volatile String health = "green";
|
||||
private volatile int requests;
|
||||
private CountDownLatch blocked;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only cluster health is exposed: status must never scan the source/index.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws Exception
|
||||
{
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
healthServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
healthServer.createContext("/", exchange ->
|
||||
{
|
||||
requests++;
|
||||
if(blocked != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
blocked.await(10, TimeUnit.SECONDS);
|
||||
}
|
||||
catch(InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
byte[] body = ("{\"status\":\"" + health + "\",\"number_of_nodes\":1,\"active_shards\":1,\"unassigned_shards\":0}").getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
exchange.close();
|
||||
});
|
||||
healthServer.start();
|
||||
instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
instance.addTable(new QTableMetaData().withName("product").withBackendName("memory").withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER)).withField(new QFieldMetaData("name", QFieldType.STRING)));
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
config = new QuickSearchQBitConfig().withBackendName("memory").withStartupMode(QuickSearchStartupMode.DEGRADED)
|
||||
.withOpensearchUrl("http://127.0.0.1:" + healthServer.getAddress().getPort()).withEnableRealTimeIndexing(false)
|
||||
.withOpensearchIndexName("status-test").withSearchableTable("product", List.of(new SearchableFieldConfig("name")));
|
||||
new QuickSearchQBitProducer().withConfig(config).produce(instance);
|
||||
insert(config.getQuickSearchIndexTableName(), new QRecord().withValue("tableName", "product").withValue("enabled", true)
|
||||
.withValue("status", "ACTIVE").withValue("documentCount", 12).withValue("lastBasepullTime", Instant.parse("2026-10-01T00:00:00Z")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Close the qbit worker, transport, and test server.
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
if(blocked != null)
|
||||
{
|
||||
blocked.countDown();
|
||||
}
|
||||
if(config != null && config.getRuntime() != null)
|
||||
{
|
||||
config.getRuntime().close();
|
||||
}
|
||||
healthServer.stop(0);
|
||||
QContext.clear();
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached values stay immutable and permissions are rechecked on cache hits.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cacheAndPermission() throws Exception
|
||||
{
|
||||
QuickSearchStatus first = QuickSearchStatusService.get();
|
||||
assertThat(first.state()).isEqualTo(QuickSearchStatus.State.OK);
|
||||
assertThat(first.clusterReachable()).isTrue();
|
||||
assertThat(first.indexes().get(0).documentCount()).isEqualTo(12);
|
||||
assertThat(first.indexes().get(0).sourceCount()).isNull();
|
||||
assertThat(first.homeHref()).isEqualTo(QuickSearchApi.HOME_HREF);
|
||||
assertThat(QuickSearchStatusService.get()).isSameAs(first);
|
||||
assertThat(requests).isEqualTo(1);
|
||||
assertThatThrownBy(() -> first.indexes().clear()).isInstanceOf(UnsupportedOperationException.class);
|
||||
QContext.init(instance, new QSession());
|
||||
assertThatThrownBy(QuickSearchStatusService::get).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(requests).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Saved validator counts and operational backlog cause degradation.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void savedValidationAndBacklog() throws Exception
|
||||
{
|
||||
Instant measured = Instant.parse("2026-10-02T00:00:00Z");
|
||||
insert(QuickSearchValidationRun.TABLE_NAME, new QRecord().withValue("status", "WARN").withValue("endTime", measured)
|
||||
.withValue("reportJson", "{\"tableStats\":[{\"tableName\":\"product\",\"databaseCount\":20,\"indexCount\":12}]}"));
|
||||
insert(QuickSearchFailedEvent.TABLE_NAME, new QRecord().withValue("status", "PENDING"));
|
||||
insert(QuickSearchFailedEvent.TABLE_NAME, new QRecord().withValue("status", "EXHAUSTED"));
|
||||
QuickSearchStatus result = QuickSearchStatusService.get();
|
||||
assertThat(result.state()).isEqualTo(QuickSearchStatus.State.DEGRADED);
|
||||
assertThat(result.pending()).isEqualTo(1);
|
||||
assertThat(result.exhausted()).isEqualTo(1);
|
||||
assertThat(result.indexes().get(0).sourceCount()).isEqualTo(20);
|
||||
assertThat(result.indexes().get(0).countsCheckedAt()).isEqualTo(measured);
|
||||
assertThat(result.lastSuccessfulSync()).isEqualTo(Instant.parse("2026-10-01T00:00:00Z"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reachability differs from healthy primaries; yellow is degraded, red down.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void healthColorsAndOffline() throws Exception
|
||||
{
|
||||
health = "yellow";
|
||||
assertThat(StatusDiagnostics.read(config.getRuntime()).state()).isEqualTo(QuickSearchStatus.State.DEGRADED);
|
||||
health = "red";
|
||||
QuickSearchStatus red = StatusDiagnostics.read(config.getRuntime());
|
||||
assertThat(red.state()).isEqualTo(QuickSearchStatus.State.DOWN);
|
||||
assertThat(red.clusterReachable()).isTrue();
|
||||
healthServer.stop(0);
|
||||
QuickSearchStatus down = StatusDiagnostics.read(config.getRuntime());
|
||||
assertThat(down.state()).isEqualTo(QuickSearchStatus.State.DOWN);
|
||||
assertThat(down.clusterReachable()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A blocked remote call has bounded caller latency and cannot grow a queue.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void timeoutDoesNotQueueMoreWork() throws Exception
|
||||
{
|
||||
blocked = new CountDownLatch(1);
|
||||
long start = System.nanoTime();
|
||||
QuickSearchStatus result = QuickSearchStatusService.get();
|
||||
assertThat(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)).isLessThan(3500);
|
||||
assertThat(result.state()).isEqualTo(QuickSearchStatus.State.DEGRADED);
|
||||
assertThat(result.clusterReachable()).isNull();
|
||||
assertThat(result.lastError()).contains("timed out");
|
||||
assertThat(QuickSearchStatusService.get()).isSameAs(result);
|
||||
assertThat(requests).isEqualTo(1);
|
||||
blocked.countDown();
|
||||
Thread.sleep(5100);
|
||||
QuickSearchStatus recovered = QuickSearchStatusService.get();
|
||||
assertThat(recovered.state()).isEqualTo(QuickSearchStatus.State.OK);
|
||||
assertThat(recovered.checkedAt()).isAfter(result.checkedAt());
|
||||
assertThat(requests).isEqualTo(2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rate limiting uses the diagnostics limiter before launching remote work.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void rateLimitBeforeRefresh() throws Exception
|
||||
{
|
||||
config.withDiagnosticRunsPerMinute(1);
|
||||
com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsSupport.checkRateLimit(config.getRuntime(), "STATUS");
|
||||
assertThatThrownBy(QuickSearchStatusService::get).hasMessageContaining("Too many");
|
||||
assertThat(requests).isZero();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bounds are explicit, not silent truncation or full counts of growing queues.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void boundedTablesAndBacklog() throws Exception
|
||||
{
|
||||
List<QRecord> indexes = new java.util.ArrayList<>();
|
||||
for(int i = 0; i < 100; i++)
|
||||
{
|
||||
indexes.add(new QRecord().withValue("tableName", "table" + i).withValue("enabled", true).withValue("status", "ACTIVE"));
|
||||
}
|
||||
new InsertAction().execute(new InsertInput(config.getQuickSearchIndexTableName()).withRecords(indexes));
|
||||
List<QRecord> backlog = new java.util.ArrayList<>();
|
||||
for(int i = 0; i < 1001; i++)
|
||||
{
|
||||
backlog.add(new QRecord().withValue("status", "AWAITING_REINDEX"));
|
||||
}
|
||||
new InsertAction().execute(new InsertInput(QuickSearchFailedEvent.TABLE_NAME).withRecords(backlog));
|
||||
QuickSearchStatus status = QuickSearchStatusService.get();
|
||||
assertThat(status.tablesTruncated()).isTrue();
|
||||
assertThat(status.backlogTruncated()).isTrue();
|
||||
assertThat(status.indexes()).hasSize(100);
|
||||
assertThat(status.pending()).isEqualTo(1000);
|
||||
assertThat(status.state()).isEqualTo(QuickSearchStatus.State.DEGRADED);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Separate QInstances cannot reuse each other's snapshots; links honor prefixes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void prefixedDisabledInstance() throws Exception
|
||||
{
|
||||
QuickSearchStatus first = QuickSearchStatusService.get();
|
||||
QInstance other = new QInstance();
|
||||
other.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
other.addTable(instance.getTable("product"));
|
||||
other.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
|
||||
QuickSearchQBitConfig otherConfig = new QuickSearchQBitConfig().withBackendName("memory").withTableNamePrefix("host_")
|
||||
.withStartupMode(QuickSearchStartupMode.DEGRADED).withOpensearchUrl("http://127.0.0.1:1")
|
||||
.withOpensearchIndexName("other").withEnableRealTimeIndexing(false)
|
||||
.withSearchableTable("product", List.of(new SearchableFieldConfig("name")));
|
||||
QContext.init(other, new QSystemUserSession());
|
||||
new QuickSearchQBitProducer().withConfig(otherConfig).produce(other);
|
||||
try
|
||||
{
|
||||
insert(otherConfig.getQuickSearchIndexTableName(), new QRecord().withValue("tableName", "product").withValue("enabled", false));
|
||||
QuickSearchStatus second = QuickSearchStatusService.get();
|
||||
assertThat(second).isNotSameAs(first);
|
||||
assertThat(second.state()).isEqualTo(QuickSearchStatus.State.DISABLED);
|
||||
assertThat(second.clusterReachable()).isNull();
|
||||
assertThat(second.homeHref()).isEqualTo("/app/host_quickSearchAdmin");
|
||||
assertThat(requests).isEqualTo(1);
|
||||
assertThat(second.toSchema()).isNotNull();
|
||||
assertThat(second.indexes().get(0).toSchema()).isNotNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
otherConfig.getRuntime().close();
|
||||
QContext.init(instance, new QSystemUserSession());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict route auditing, real authentication, 403 after cache population,
|
||||
** and the same serialized snapshot through the production HTTP pipeline.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void strictHttpContract() throws Exception
|
||||
{
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(TestSessions.class)));
|
||||
int port;
|
||||
try(ServerSocket socket = new ServerSocket(0))
|
||||
{
|
||||
port = socket.getLocalPort();
|
||||
}
|
||||
QApplicationJavalinServer server = new QApplicationJavalinServer(new AbstractQQQApplication()
|
||||
{
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return (instance);
|
||||
}
|
||||
}).withPort(port).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true);
|
||||
try(HttpClient client = HttpClient.newHttpClient())
|
||||
{
|
||||
server.start();
|
||||
String url = "http://127.0.0.1:" + port + "/qqq/v1/qbit/quick-search/status";
|
||||
String suffix = UUID.randomUUID().toString();
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(url)).header("Cookie", "sessionUUID=admin-" + suffix).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
var json = new ObjectMapper().readTree(response.body());
|
||||
assertThat(json.path("homeHref").asText()).isEqualTo(QuickSearchApi.HOME_HREF);
|
||||
assertThat(json.path("checkedAt").isTextual()).isTrue();
|
||||
assertThat(json.path("indexes").isArray()).isTrue();
|
||||
java.util.Set<String> fields = new java.util.HashSet<>();
|
||||
json.fieldNames().forEachRemaining(fields::add);
|
||||
assertThat(fields).containsExactlyInAnyOrderElementsOf(new com.kingsrook.qbits.quicksearch.api.QuickSearchStatusSpec()
|
||||
.defineComponentSchemas().get("QuickSearchStatusV1").getProperties().keySet());
|
||||
var anonymous = client.send(HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
// MOCK authentication supplies a session even without cookies; it has no admin permission.
|
||||
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);
|
||||
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");
|
||||
}
|
||||
finally
|
||||
{
|
||||
server.stop();
|
||||
QJavalinImplementation.setSessionCookieHttpOnly(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Production mock-auth customization with distinct administrator and reader.
|
||||
*******************************************************************************/
|
||||
public static class TestSessions implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Grant only the administrator the existing qbit admin permission.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance instance, QSession session, Map<String, Object> context)
|
||||
{
|
||||
session.withUser(new QUser().withIdReference(session.getUuid()));
|
||||
if(session.getUuid().startsWith("admin-"))
|
||||
{
|
||||
session.withPermissions("quick_search_admin.hasAccess");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert fixture metadata using the same backend actions as production.
|
||||
*******************************************************************************/
|
||||
private void insert(String table, QRecord row) throws Exception
|
||||
{
|
||||
new InsertAction().execute(new InsertInput(table).withRecords(List.of(row)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user