Compare commits

..
Author SHA1 Message Date
james.maesandClaude Opus 5.5 e06b06000b fix(indexing): real-time inserts index display values (#28)
Real-time INSERT events were indexed from the event's own records, which
carry raw values only, so a foreign key was indexed as its id and the
record label as "2 - email" until the next basepull. Inserts now take the
same path as updates: the listener re-reads the written records inside
the write's transaction with display values and possible-value labels.

A failed re-read is handled as for updates (#13): logged at once, then
recorded in quickSearchFailedEvent after commit for basepull replay,
never failing the host's insert, and nothing recorded on rollback.

Tests: listener unit tests for the PVS label and record label of a
just-inserted record, and for a failed insert re-read (commit and
rollback); an OpenSearch Testcontainers test that a just-inserted
record is searchable by its possible-value label and carries it in
fieldValues and the record label.

Closes #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:54:35 -05:00
james.maes 2b627e767a docs: record the 1.0.0-RC.2 release (#12) (#23)
CHANGELOG gains the RC.2 entry and the #13 to #17 fixes; TODO and SESSION-STATE reflect RC.2 and the remaining GA items.
2026-10-06 21:12:27 -05:00
12 changed files with 281 additions and 150 deletions

No files matched your search

+68 -11
View File
@@ -1,14 +1,71 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.7.15
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.15
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /v.*/
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*|hotfix\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/v.*/]
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\/.*/]
-23
View File
@@ -1,23 +0,0 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
-74
View File
@@ -1,74 +0,0 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original Circle publisher build number (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
repository_dispatch:
types:
- qbit-rc-published
permissions:
contents: write
actions: read
checks: read
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
== 'workflow_dispatch' && inputs.finalize) }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|| inputs.merge_sha || '' }}
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|| inputs.bump_sha || '' }}
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|| inputs.branch || '' }}
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|| inputs.version || '' }}
secrets: inherit
-16
View File
@@ -1,16 +0,0 @@
profile: qbit
orb_version: 0.7.15
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: v-prefix
test_branch_policy: non-release-no-hotfix
publish:
snapshot: true
release_candidate: true
release: true
hotfix: true
asciidoc: false
+14
View File
@@ -2,6 +2,10 @@
All notable changes to this project are documented here. The format follows Keep a Changelog; versions follow semantic versioning.
## [1.0.0-RC.2] - 2026-10-06
Second release candidate, published to Maven Central from `release/1.0`. Fixes the five pre-GA findings from the PR #10 review (#13 to #17); the changes are listed under 1.0.0 below. Requires QQQ 4.1. No schema change, but two new status values: `REBUILDING` on `quickSearchIndex.status` and `AWAITING_REINDEX` on `quickSearchFailedEvent.status`; a host that sized its own `quickSearchFailedEvent.status` column needs at least 16 characters. Known gaps before GA are tracked in #12.
## [1.0.0-RC.1] - 2026-10-05
First release candidate of 1.0, published to Maven Central from `release/1.0`. It contains every change listed under 1.0.0 below. Requires QQQ 4.1 (built against 4.1.0-RC.1). Known gaps before GA are tracked in #12.
@@ -35,13 +39,23 @@ Requires QQQ 4.1. Closes every blocker of the 2026-10-04 production-readiness au
- `IndexingUtils.normalizeSearchText` no longer lower-cases (analyzers do).
- `enableScheduledProcesses` is deprecated in favour of `enableBasepullProcess` and `enableMaintenanceProcesses`.
- `QuickSearchQBitContext` is deprecated; state lives on `QuickSearchRuntime`, resolved from the `QInstance`.
- Search `offset` counts results the session can read. With record security locks on, hits are scanned from the start in batches of at most 1,000, filtered for access and deduplicated; one call reads at most 10,000 hits over 32 requests, and in per-table mode each of T tables gets max(10,000 / T, 2 x `limitPerTable`) hits. When that share runs out `hasMore` is false and `totalHitsIsLowerBound` is true. With locks on, `totalHits` counts hits confirmed readable. Per-table mode now honours `offset` (#16).
- A full reindex of all tables marks each table `REBUILDING` while it runs; drift detection is suspended for those tables. After a killed run, run the full reindex again (#14).
### Removed
- The three table customizers (`QuickSearchPostInsertCustomizer`, `QuickSearchPostUpdateCustomizer`, `QuickSearchPostDeleteCustomizer`), replaced by `QuickSearchRecordChangeListener`.
- Support for QQQ 4.0.
### Security
- `opensearchUrl` with userinfo (`https://user:pass@host`) fails validation; an `${env.X}` or `${prop.X}` credential or TLS path that resolves to an empty or blank value fails transport creation and names the field, never the value; `tls.hostnameVerification=false` logs a warning (#15).
### Fixed
- Rolled-back writes could be indexed; real-time failures were silent; basepull lost rows modified during a run and rows at the boundary second; unordered paging skipped or duplicated rows; full reindex blanked search; search leaked labels and snippets across tables regardless of permissions; mixed field types across tables broke indexing; non-text searchable fields failed every search; record labels were null for annotation tables; duplicate `quickSearchIndex` rows on concurrent first runs; unbounded `limit`/`offset`; unchecked `OpenSearchException`; unreachable cluster at startup left the host healthy with search broken.
- Deletes made during a full reindex of all tables were lost from the new index; they are now captured and applied after the alias swap (#14).
- An update was dropped without a `quickSearchFailedEvent` row when the listener's re-read failed or the optional AWS SDK was missing (`LinkageError`); both are now recorded for basepull, and a `DEGRADED` start without the AWS SDK no longer aborts host startup (#13).
- Search pages overlapped under record locks, per-table mode ignored `offset`, and a large `maxSearchLimit` could exceed the 10,000 result window (#16).
- A failing document count was swallowed and cleared `documentCount`; it is now logged at warn and the previous count is kept (#17).
- A real-time insert was indexed with raw values (a foreign key as its id, the record label as "2 - email") until the next basepull. Inserts are now re-read in the write's transaction with display values and possible-value labels, as updates are; a failed re-read is recorded in `quickSearchFailedEvent` for basepull and never fails the insert (#28).
## [0.2.0] - 2026-03-30
- Config-driven table indexing (`SearchableTableConfig`).
+1
View File
@@ -20,6 +20,7 @@
- Real-time indexing happens after commit. Code that searched for a record inside the same transaction that inserted it will not find it until commit.
- Real-time failures no longer vanish: they land in `quickSearchFailedEvent` and are replayed by the next basepull. Review that table after an OpenSearch outage.
- Inserted and updated records are re-read inside the write's transaction, with display values and possible-value labels, before they are indexed. That is one extra query per write batch on an indexed table, and it runs the host's possible-value providers. If the re-read fails, the write still succeeds and the records are replayed by basepull.
- A table without the change-detection field (`modifyDate` by default) is indexed on the first basepull and thereafter only by reconcile.
- `limit` is capped (default 100), `offset + limit` at 10,000, and search terms must be 2 to 100 characters.
- Query semantics changed from OR over n-grams to AND over whole terms with prefix matching: "blue widget" now requires both words.
+18 -6
View File
@@ -1,19 +1,31 @@
# Session State
Updated: 2026-10-05
Updated: 2026-10-06
## Context
`1.0.0-RC.1` is released (2026-10-05). PR #10 merged as `0cd892d` and PR #8 as `36bc0fb`, both on `develop`. `release/1.0` was cut from `36bc0fb`; the orb's `publish_release_candidate` workflow succeeded, pushed `0b7bc98` ("Bump version to 1.0.0-RC.1 [skip ci]") to `release/1.0`, and published to Maven Central (jar, pom, sources, javadoc, signatures). A clean consumer resolves it with qqq-backend-core 4.1.0-RC.1 and opensearch-java 3.10.0.
`1.0.0-RC.2` is released (2026-10-06). It fixes the five pre-GA findings from the PR #10 review, each on its own branch and PR into `develop`:
| Issue | PR | Merge commit | Fix |
|---|---|---|---|
| #15 | #18 | `2398e62` | Transport: warn on `hostnameVerification=false`, blank `${env.X}` treated as missing, userinfo in `opensearchUrl` rejected |
| #17 | #21 | `e9ec54a` | `safeCount` logs at warn and keeps the previous `documentCount` |
| #16 | #19 | `9941a70` | Search `offset` counts readable results under record locks; bounded scan; 10,000 window cap |
| #14 | #20 | `dce0f26` | Deletes during a full reindex captured (`REBUILDING`, `AWAITING_REINDEX`) and applied after the alias swap |
| #13 | #22 | `c5c41d4` | Listener re-read failure and missing AWS SDK (`LinkageError`) recorded as failed events |
`develop` (`c5c41d4`) was merged into `release/1.0` as `b7d366c` (only difference from develop: `<revision>1.0.0-RC.1</revision>`). The orb's `publish_release_candidate` workflow succeeded on `b7d366c` (CircleCI build 82) and pushed `c11b2c2` ("Bump version to 1.0.0-RC.2 [skip ci]"). Maven Central serves the jar, pom, sources, javadoc and signatures for `com.kingsrook.qbits:qbit-quick-search:1.0.0-RC.2`.
## Release mechanics (qqq-orb 0.6.8, confirmed in its source)
- RCs publish artifacts only: no git tag and no GitHub release. The GitHub release comes when the release branch merges to `main` and a `vX.Y.Z` tag is pushed.
- Never push a `vX.Y.Z-RC.n` tag: it matches the GA job's `v*` filter, maps back to `release/X.Y.Z`, and would publish a stray `RC.n+1`.
- The next push to `release/1.0` publishes `1.0.0-RC.2`.
- The agent cannot run `gh pr merge` (auto-mode classifier); James merges. GitHub SSH times out from this machine; push over HTTPS with `git -c credential.helper='!gh auth git-credential' push https://github.com/QRun-IO/qbit-quick-search.git <ref>`.
- The next push to `release/1.0` publishes `1.0.0-RC.3`. Keep the release branch's `<revision>` on merge.
- `develop` requires one approving review and the PR author cannot approve their own PR; James merges with `gh pr merge <n> --squash --admin`. The agent cannot run `gh pr merge`, and the auto-mode classifier also refused a background loop that polled PR merge state.
- GitHub SSH times out from this machine; push over HTTPS with `git -c credential.helper='!gh auth git-credential' push https://github.com/QRun-IO/qbit-quick-search.git <ref>`.
## Next
1. Pin `1.0.0-RC.1` in qqq-all and Voyage (see `docs/TODO.md`); their feedback decides RC.2 or GA.
2. Work the "Before 1.0.0 GA" list in `docs/TODO.md` on feature branches into `develop`, then merge `develop` into `release/1.0` for RC.2.
1. qqq-all and Voyage pin `1.0.0-RC.2` and report back; their feedback decides RC.3 or GA.
2. Remaining "Before 1.0.0 GA" items in `docs/TODO.md`: stuck `REBUILDING` after a killed or overlapping full reindex (from the PR #20 review), and the `qbit-build-parent` 2.1.0 re-pin.
3. GA: merge `release/1.0` to `main` and tag `v1.0.0`.
+9 -6
View File
@@ -2,7 +2,7 @@
## Before tagging 1.0.0
- [ ] qqq-all and Voyage adopt `1.0.0-RC.1` (items below) and report back.
- [ ] qqq-all and Voyage adopt `1.0.0-RC.2` (items below) and report back.
- [x] PR #10 (`feature/GH-1-0-release-readiness`) opened and reviewed 2026-10-05; `AGENTS.md` kept.
- [ ] Re-pin to `qbit-build-parent` 2.1.0 when qbit-bom publishes it, and remove the `activeByDefault` on the `qqq-snapshot` profile (ADR-0007).
@@ -17,11 +17,14 @@
## Before 1.0.0 GA (from the PR #10 review, 2026-10-05; filed as #13 to #17 under #12)
- [ ] Listener: if the in-transaction re-read in `fetchCurrentRecords` throws, record a `quickSearchFailedEvent` row from the event's primary keys instead of dropping the update (`QuickSearchRecordChangeListener`).
- [ ] Full reindex: deletes that arrive during a rebuild are not replayed into the new index; capture them (failed-event or a delete log) and apply after the swap.
- [ ] Transport: warn when `tls.hostnameVerification=false`; treat an empty `${env.X}` value as missing and name the field; reject userinfo in `opensearchUrl`.
- [ ] Search: with record locks on, `offset` counts raw hits, so pages can overlap; per-table mode ignores `offset`; cap `fetchSize` at the 10,000 window.
- [ ] `safeCount` in `BasepullIndexStep` swallows exceptions silently; log at warn.
- [x] #13 Listener: a failed in-transaction re-read or a missing AWS SDK (`LinkageError`) records `quickSearchFailedEvent` rows (PR #22).
- [x] #14 Full reindex: deletes made during a rebuild are captured (`REBUILDING`, `AWAITING_REINDEX`) and applied after the alias swap (PR #20).
- [x] #15 Transport: warn on `tls.hostnameVerification=false`, empty `${env.X}` treated as missing, userinfo in `opensearchUrl` rejected (PR #18).
- [x] #16 Search: `offset` counts readable results under record locks, per-table `offset`, bounded scan and 10,000 window cap (PR #19).
- [x] #17 `safeCount` logs at warn and keeps the previous `documentCount` (PR #21).
- [x] `1.0.0-RC.2` published to Maven Central from `release/1.0` (2026-10-06).
- [ ] Stuck `REBUILDING` after a killed or overlapping full reindex (from the PR #20 review): only another all-tables reindex clears it, `AWAITING_REINDEX` rows accumulate and drift detection stays off for those tables. No data is lost. Fix needs a liveness check on the `FULL_REINDEX` run record before reconcile may reset the status, plus refusing a second concurrent full reindex.
- [ ] Drift detection still writes `NEEDS_REINDEX` into `status`, so a few-millisecond window can overwrite `REBUILDING`; the durable fix is a separate drift field (schema change, so 1.1 at the earliest).
## Later (1.x)
+1 -1
View File
@@ -18,7 +18,7 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>1.0.0-RC.2</revision>
<revision>1.0.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<opensearch.version>3.10.0</opensearch.version>
@@ -51,14 +51,20 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
** Real-time indexing hook. Registered once per QInstance; applies to the
** tables this QBit indexes.
**
** Events are built while the write's transaction is open (so updates can be
** re-read in full), but published only after that transaction commits, via
** Events are built while the write's transaction is open, but published only
** after that transaction commits, via
** QBackendTransaction.addAfterCommitCallback. A write without a transaction
** is already committed, so it publishes immediately. A rolled-back write never
** reaches the index.
**
** This listener never fails the host's write. When the re-read of updated
** records or publishing fails (including a LinkageError from a missing
** Inserted and updated records are re-read inside that transaction, with
** display values and possible-value labels, so a real-time document matches
** the one basepull, reconcile and full reindex build. The event's own records
** are not enough: for an update they are the sparse input, and for an insert
** they carry only raw values.
**
** This listener never fails the host's write. When the re-read of inserted or
** updated records or publishing fails (including a LinkageError from a missing
** optional library while the client is built), each affected (table, record,
** action) is written to quickSearchFailedEvent for the scheduled basepull to
** replay. A failed re-read is recorded after the commit too, so a rolled-back
@@ -115,8 +121,7 @@ public class QuickSearchRecordChangeListener implements RecordChangeListenerInte
switch(event.getType())
{
case INSERT -> indexEvents.addAll(toIndexEvents(tableName, primaryKeyField, event.getRecords()));
case UPDATE ->
case INSERT, UPDATE ->
{
try
{
@@ -127,10 +132,11 @@ public class QuickSearchRecordChangeListener implements RecordChangeListenerInte
///////////////////////////////////////////////////////////////////////
// log now: with a transaction, nothing else is logged until commit, //
// and a rollback (a transaction the failed re-read aborted, too) //
// would leave no trace. Then record the updated keys after commit, //
// would leave no trace. Then record the written keys after commit, //
// so basepull re-reads and indexes them. //
///////////////////////////////////////////////////////////////////////
LOG.warn("Could not re-read updated records for indexing; recording them for replay once the write commits", e,
LOG.warn("Could not re-read " + (event.getType() == RecordChangeType.INSERT ? "inserted" : "updated")
+ " records for indexing; recording them for replay once the write commits", e,
logPair("tableName", tableName), logPair("recordCount", event.getRecords().size()));
List<IndexEvent> failedEvents = toIndexEvents(tableName, primaryKeyField, event.getRecords());
runAfterCommit(event, () -> recordFailures(runtime, failedEvents, e));
@@ -444,8 +450,9 @@ public class QuickSearchRecordChangeListener implements RecordChangeListenerInte
/*******************************************************************************
** Updated records are "as the backend returned them", which for RDBMS is the
** sparse input. Re-read the full rows inside the same transaction, with
** The event's records are "as the backend returned them": for an update on
** RDBMS that is the sparse input, and for an insert the raw values with no
** display values. Re-read the full rows inside the same transaction, with
** display values so possible-value labels are indexed (which can run host
** possible-value providers).
*******************************************************************************/
@@ -39,6 +39,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
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.modules.backend.implementations.memory.MemoryBackendModule;
@@ -64,7 +65,8 @@ import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Integration tests, against a real OpenSearch, that real-time indexing keeps
** the index in step with the source table through updates and deletes, and
** the index in step with the source table through inserts (with display
** values), updates and deletes, and
** that the reconcile process repairs an index that has drifted.
**
** Each test uses its own record IDs and search terms, so tests are independent.
@@ -75,6 +77,7 @@ class IndexDriftIntegrationTest
{
private static final String BACKEND_NAME = "driftMemoryBackend";
private static final String TABLE_NAME = "driftCustomer";
private static final String REGION_TABLE = "driftRegion";
private static final String INDEX_NAME = "quick_search_drift_test";
@Container
@@ -98,6 +101,9 @@ class IndexDriftIntegrationTest
@QuickSearchField
private String city;
@QuickSearchField
private Integer regionId;
}
@@ -124,7 +130,19 @@ class IndexDriftIntegrationTest
.withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("name", QFieldType.STRING))
.withField(new QFieldMetaData("city", QFieldType.STRING)));
.withField(new QFieldMetaData("city", QFieldType.STRING))
.withField(new QFieldMetaData("regionId", QFieldType.INTEGER).withPossibleValueSourceName(REGION_TABLE))
.withRecordLabelFormat("%s - %s")
.withRecordLabelFields("name", "regionId"));
qInstance.addTable(new QTableMetaData()
.withName(REGION_TABLE)
.withBackendName(BACKEND_NAME)
.withPrimaryKeyField("id")
.withRecordLabelFormat("%s")
.withRecordLabelFields("name")
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("name", QFieldType.STRING)));
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(REGION_TABLE));
QuickSearchQBitConfig config = new QuickSearchQBitConfig()
.withBackendName(BACKEND_NAME)
@@ -154,6 +172,33 @@ class IndexDriftIntegrationTest
/*******************************************************************************
** Test: a just-inserted record's document carries display values: the
** possible-value label of a foreign key is searchable and is in the record
** label, with no basepull in between (#28).
*******************************************************************************/
@Test
void testInsert_documentCarriesDisplayValuesAndPossibleValueLabel() throws Exception
{
InsertInput regionInsert = new InsertInput();
regionInsert.setTableName(REGION_TABLE);
regionInsert.setRecords(List.of(new QRecord().withValue("id", 7).withValue("name", "Northwind Territory")));
new InsertAction().execute(regionInsert);
insert(new QRecord().withValue("id", 31).withValue("name", "Rhoda").withValue("city", "Labelburg").withValue("regionId", 7));
client().refreshIndex();
List<QuickSearchResult> results = new QuickSearchAction().execute(new QuickSearchInput().withSearchTerm("northwind").withTableName(TABLE_NAME)).getResults();
assertThat(results).as("the region's label, not its key, is indexed").extracting(QuickSearchResult::getRecordId).containsExactly("31");
assertThat(results.get(0).getRecordLabel()).isEqualTo("Rhoda - Northwind Territory");
Map<String, Object> fieldValues = client().search("labelburg", List.of(TABLE_NAME), 10, 0, QuickSearchQBitContext.getDiscoveredTables())
.hits().hits().get(0).source().getFieldValues();
assertThat(fieldValues).containsEntry("regionId", "Northwind Territory");
}
/*******************************************************************************
** Test: a partial update (one field) keeps the other fields searchable.
*******************************************************************************/
@@ -37,7 +37,11 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
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.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qbits.quicksearch.BaseQuickSearchTest;
import com.kingsrook.qbits.quicksearch.MissingLibraryTransportCustomizer;
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
@@ -74,6 +78,8 @@ import static org.mockito.Mockito.when;
*******************************************************************************/
class QuickSearchRecordChangeListenerTest extends BaseQuickSearchTest
{
private static final String COMPANY_TABLE = "company";
private IndexEventPublisher publisher;
private QuickSearchRuntime runtime;
@@ -100,6 +106,25 @@ class QuickSearchRecordChangeListenerTest extends BaseQuickSearchTest
config.setRuntime(runtime);
QInstance qInstance = QContext.getQInstance();
///////////////////////////////////////////////////////////////////
// a foreign key to a company table, through a table PVS, and a //
// record label over it, so display values can be asserted //
///////////////////////////////////////////////////////////////////
qInstance.addTable(new QTableMetaData()
.withName(COMPANY_TABLE)
.withBackendName(TEST_BACKEND_NAME)
.withPrimaryKeyField("id")
.withRecordLabelFormat("%s")
.withRecordLabelFields("name")
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData("name", QFieldType.STRING)));
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(COMPANY_TABLE));
qInstance.getTable(TEST_ENTITY_TABLE)
.withField(new QFieldMetaData("companyId", QFieldType.INTEGER).withPossibleValueSourceName(COMPANY_TABLE))
.withRecordLabelFormat("%s - %s")
.withRecordLabelFields("name", "companyId");
qInstance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("quick-search").withVersion("test").withConfig(config));
qInstance.withRecordChangeListener(new QCodeReference(QuickSearchRecordChangeListener.class));
}
@@ -145,6 +170,79 @@ class QuickSearchRecordChangeListenerTest extends BaseQuickSearchTest
@Test
void testInsert_indexesDisplayValuesAndPossibleValueLabels() throws QException
{
InsertInput companyInsert = new InsertInput();
companyInsert.setTableName(COMPANY_TABLE);
companyInsert.setRecords(List.of(new QRecord().withValue("name", "Acme Corp")));
Integer companyId = new InsertAction().execute(companyInsert).getRecords().get(0).getValueInteger("id");
InsertInput insertInput = new InsertInput();
insertInput.setTableName(TEST_ENTITY_TABLE);
insertInput.setRecords(List.of(new QRecord().withValue("name", "widget").withValue("description", "d").withValue("companyId", companyId)));
new InsertAction().execute(insertInput);
ArgumentCaptor<List<IndexEvent>> captor = ArgumentCaptor.forClass(List.class);
verify(publisher, times(1)).publishIndexEvents(captor.capture());
QRecord indexed = captor.getValue().get(0).getRecord();
assertThat(indexed.getValueInteger("companyId")).isEqualTo(companyId);
assertThat(indexed.getDisplayValue("companyId")).as("the PVS label, not the raw key").isEqualTo("Acme Corp");
assertThat(indexed.getRecordLabel()).isEqualTo("widget - Acme Corp");
}
@Test
void testInsert_reReadFails_recordsFailedEventsAfterCommit() throws QException
{
QBackendTransaction transaction = new QBackendTransaction();
try(MockedConstruction<QueryAction> ignored = failingQueries(new QException("possible-value backend unavailable")))
{
assertThatCode(() -> new QuickSearchRecordChangeListener().onRecordsChanged(changeEvent(RecordChangeType.INSERT, transaction, 7, 8))).doesNotThrowAnyException();
}
assertThat(queryFailedEvents()).isEmpty();
transaction.commit();
List<QRecord> failed = queryFailedEvents();
assertThat(failed).extracting(r -> r.getValueString("recordId")).containsExactlyInAnyOrder("7", "8");
assertThat(failed).allSatisfy(r ->
{
assertThat(r.getValueString("action")).isEqualTo("INDEX");
assertThat(r.getValueString("status")).isEqualTo(QuickSearchFailedEvent.STATUS_PENDING);
assertThat(r.getValueString("errorMessage")).contains("possible-value backend unavailable");
});
verify(publisher, never()).publishIndexEvents(anyList());
}
@Test
void testInsert_reReadFailsThenRollback_logsCauseAndRecordsNothing() throws QException
{
QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(QuickSearchRecordChangeListener.class);
QBackendTransaction transaction = new QBackendTransaction();
try(MockedConstruction<QueryAction> ignored = failingQueries(new NoClassDefFoundError("com/example/pvs/Provider")))
{
new QuickSearchRecordChangeListener().onRecordsChanged(changeEvent(RecordChangeType.INSERT, transaction, 7));
}
finally
{
QLogger.deactivateCollectingLoggerForClass(QuickSearchRecordChangeListener.class);
}
transaction.rollback();
assertThat(queryFailedEvents()).isEmpty();
assertThat(collectingLogger.getCollectedMessages()).anySatisfy(m ->
{
assertThat(m.getLevel()).isEqualTo(Level.WARN);
assertThat(m.getMessage()).contains("Could not re-read inserted records").contains(TEST_ENTITY_TABLE).contains("com/example/pvs/Provider");
});
}
@Test
void testInsert_withTransaction_publishesOnlyAfterCommit() throws QException
{
@@ -470,13 +568,20 @@ class QuickSearchRecordChangeListenerTest extends BaseQuickSearchTest
private RecordChangeEvent updateEvent(QBackendTransaction transaction, Integer... ids)
{
return (changeEvent(RecordChangeType.UPDATE, transaction, ids));
}
private RecordChangeEvent changeEvent(RecordChangeType type, QBackendTransaction transaction, Integer... ids)
{
List<QRecord> records = new ArrayList<>();
for(Integer id : ids)
{
records.add(new QRecord().withValue("id", id).withValue("description", "changed"));
}
return (new RecordChangeEvent().withTableName(TEST_ENTITY_TABLE).withType(RecordChangeType.UPDATE).withRecords(records).withTransaction(transaction));
return (new RecordChangeEvent().withTableName(TEST_ENTITY_TABLE).withType(type).withRecords(records).withTransaction(transaction));
}