Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb429f7cbb
|
||
|
|
d67dec8448 | ||
|
|
fd34b1c6c8 | ||
|
|
00148af810 | ||
|
|
15a564258b | ||
|
|
4437720546
|
||
|
|
e4bf215310 | ||
|
|
d76391f6a4
|
||
|
|
fd0322b8dc | ||
|
|
3efff6de95 | ||
|
|
d4d5029691
|
||
|
|
3de57f534b
|
||
|
|
8ef66f6efe |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -11,6 +11,8 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
Requires QQQ 4.1.0-RC.15 or later (central health checks, help bundles, the spec-managed UI API extension contract, and `ClusterCaches` for multi-node deployments).
|
||||
|
||||
### Fixed
|
||||
- Single-node clusters: the Quick Search index no longer asks for a replica it cannot place, so a one-node OpenSearch stays green instead of yellow and `quick_search.opensearch` is no longer DEGRADED. New `indexReplicas` (default null: automatic, `min(maxAutoReplicas, data nodes - 1)`) and `maxAutoReplicas` (default 1) config; zero config gives 0 replicas on one node and 1 on larger clusters. The target is set on every new physical index and reconciled on existing ones at startup with `PUT /<index>/_settings` when it differs (idempotent, logged; a failure is logged and reported, never blocks startup; indexes with `auto_expand_replicas` and OpenSearch Serverless are left alone). The status snapshot and the `quick_search.opensearch` health details gain `unassignedReplicaShards`, `targetReplicas` and `replicaSettingsFailed`; yellow on a single node with every primary active (only replicas unassigned) is reported UP with a note. Yellow with an unassigned primary, or on a multi-node cluster, stays DEGRADED; red stays DOWN.
|
||||
- Priming config records at startup, and recording diagnostic events, build their system session with the instance, so it has its all-access keys. Startup logged "QInstance was not supplied when creating a QSystemUserSession" once per node.
|
||||
- Multi-node deployments: a Quick Search config change (an index row, its fields or the settings row, from the admin UI or a config process) now clears the cached config on every node, not only the node that made the change. The runtime's config cache (the config store snapshot and settings, and the per-table enabled flags) is registered with QQQ `ClusterCaches` as `qbit-quick-search.config`, and `QuickSearchRuntime.invalidateConfig()` publishes a cluster invalidation. The 30 s and 60 s TTLs remain as the safety net. `QuickSearchFieldRules`' warn-once table set is registered as node-local. `QuickSearchClusterCachesTest` audits the package for unregistered static caches and covers a remote invalidation.
|
||||
- Indexing bookkeeping is no longer record-audited: the `quick_search_index` runtime fields written by basepull, full reindex and real-time indexing (status, last basepull time, document count, last run, real-time error count) and the settings row's priming outcome (`lastPrimedAt`, summary) are written with `omitDmlAudit`. On a production host these wrote about 470 "Record was Edited during process: Update search index" audit rows an hour. Admin edits to an index, its fields or the settings are still audited.
|
||||
- Real-time indexing failures now increase the table's error count by the number of failed events. Successful basepulls and full reindexes reset the count. Counter update failures are logged without failing the source write.
|
||||
|
||||
@@ -227,6 +227,8 @@ The panel's status is Indexed, Stale, Not indexed, Disabled (the table is turned
|
||||
| `runHistoryRetentionDays` | 30 | Purge run records older than this; null keeps them |
|
||||
| `fullReindexStaleMinutes` | 30 | A running full reindex that shows no sign of life for this long is treated as stopped: it no longer blocks a new one, and basepull recovers its tables. Keep it well above the time one page of `sourceBatchSize` records takes; null means the default |
|
||||
| `bulkBatchSize`, `maxBulkRequestBytes`, `sourceBatchSize` | 500, 5 MiB, 1000 | Batch sizes |
|
||||
| `indexReplicas` | null (automatic) | Index `number_of_replicas`; null means `min(maxAutoReplicas, data nodes - 1)`, so a single node gets 0 and stays green. Set on new indexes, reconciled on existing ones at startup |
|
||||
| `maxAutoReplicas` | 1 | Most replicas automatic sizing asks for |
|
||||
| `maxFieldLength` | 10000 | Truncate each indexed value |
|
||||
| `maxSearchLimit` | 100 | Largest page size |
|
||||
| `applyRecordSecurityLocks` | `true` | Re-read hits through `QueryAction` |
|
||||
|
||||
@@ -81,6 +81,7 @@ public class QuickSearchQBitConfig implements QBitConfig
|
||||
public static final int DEFAULT_VALIDATION_SAMPLE_SIZE = 50;
|
||||
public static final int MAX_VALIDATION_SAMPLE_SIZE = 1000;
|
||||
public static final int DEFAULT_DIAGNOSTIC_RUNS_PER_MINUTE = 30;
|
||||
public static final int DEFAULT_MAX_AUTO_REPLICAS = 1;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// a live full reindex refreshes its run record after every page; a page is //
|
||||
@@ -113,6 +114,8 @@ public class QuickSearchQBitConfig implements QBitConfig
|
||||
private Integer responseTimeoutMillis = 60000;
|
||||
private Integer maxConnections = 30;
|
||||
private Integer maxBulkRequestBytes = 5 * 1024 * 1024;
|
||||
private Integer indexReplicas;
|
||||
private Integer maxAutoReplicas = DEFAULT_MAX_AUTO_REPLICAS;
|
||||
private QCodeReference transportCustomizer;
|
||||
private QuickSearchStartupMode startupMode = QuickSearchStartupMode.FAIL_FAST;
|
||||
private Boolean enableRealTimeIndexing = true;
|
||||
@@ -443,6 +446,16 @@ public class QuickSearchQBitConfig implements QBitConfig
|
||||
{
|
||||
errors.add("basepullOverlapSeconds must not be negative for QuickSearchQBit");
|
||||
}
|
||||
|
||||
if(indexReplicas != null && indexReplicas < 0)
|
||||
{
|
||||
errors.add("indexReplicas must not be negative for QuickSearchQBit");
|
||||
}
|
||||
|
||||
if(maxAutoReplicas != null && maxAutoReplicas < 0)
|
||||
{
|
||||
errors.add("maxAutoReplicas must not be negative for QuickSearchQBit");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1495,6 +1508,73 @@ public class QuickSearchQBitConfig implements QBitConfig
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for indexReplicas: number_of_replicas for the Quick Search index.
|
||||
** Null (the default) means automatic: min(maxAutoReplicas, data nodes - 1),
|
||||
** so a single-node cluster gets 0 replicas and stays green. Applied when a
|
||||
** physical index is created and reconciled on existing indexes at startup.
|
||||
** Ignored for OpenSearch Serverless (awsServiceName aoss).
|
||||
*******************************************************************************/
|
||||
public Integer getIndexReplicas()
|
||||
{
|
||||
return (this.indexReplicas);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for indexReplicas
|
||||
*******************************************************************************/
|
||||
public void setIndexReplicas(Integer indexReplicas)
|
||||
{
|
||||
this.indexReplicas = indexReplicas;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for indexReplicas
|
||||
*******************************************************************************/
|
||||
public QuickSearchQBitConfig withIndexReplicas(Integer indexReplicas)
|
||||
{
|
||||
this.indexReplicas = indexReplicas;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for maxAutoReplicas: the most replicas automatic mode (indexReplicas
|
||||
** null) asks for; it never asks for more than data nodes - 1. Default 1.
|
||||
*******************************************************************************/
|
||||
public Integer getMaxAutoReplicas()
|
||||
{
|
||||
return (this.maxAutoReplicas);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for maxAutoReplicas
|
||||
*******************************************************************************/
|
||||
public void setMaxAutoReplicas(Integer maxAutoReplicas)
|
||||
{
|
||||
this.maxAutoReplicas = maxAutoReplicas;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for maxAutoReplicas
|
||||
*******************************************************************************/
|
||||
public QuickSearchQBitConfig withMaxAutoReplicas(Integer maxAutoReplicas)
|
||||
{
|
||||
this.maxAutoReplicas = maxAutoReplicas;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for transportCustomizer: QCodeReference to an OpenSearchTransportCustomizer
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -758,7 +758,7 @@ public class QuickSearchRuntime
|
||||
{
|
||||
return;
|
||||
}
|
||||
QContext.init(qInstance, new QSystemUserSession());
|
||||
QContext.init(qInstance, new QSystemUserSession(qInstance));
|
||||
try
|
||||
{
|
||||
configStore.tryPrime();
|
||||
|
||||
@@ -30,11 +30,41 @@ import com.kingsrook.qqq.openapi.model.Schema;
|
||||
/*******************************************************************************
|
||||
** Immutable administrative snapshot. Null measurements are unknown, never zero.
|
||||
** Counts come from persisted diagnostics; countsCheckedAt identifies their age.
|
||||
**
|
||||
** unassignedReplicaShards is the unassigned shard count when every primary is
|
||||
** active (so all unassigned shards are replicas), else null; targetReplicas and
|
||||
** replicaSettingsFailed report the index replica sizing (null when unknown).
|
||||
*******************************************************************************/
|
||||
public record QuickSearchStatus(State state, Boolean clusterReachable, String clusterHealth, Integer nodes, Integer activeShards, Integer unassignedShards,
|
||||
Integer unassignedReplicaShards, Integer targetReplicas, Boolean replicaSettingsFailed,
|
||||
Boolean indexingPaused, List<IndexRow> indexes, Integer pending, Integer exhausted, Boolean backlogTruncated,
|
||||
Boolean tablesTruncated, Instant lastSuccessfulSync, String lastError, Instant checkedAt, String homeHref) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The 1.1.0-RC.14 shape, without the replica measurements (all unknown).
|
||||
*******************************************************************************/
|
||||
public QuickSearchStatus(State state, Boolean clusterReachable, String clusterHealth, Integer nodes, Integer activeShards, Integer unassignedShards,
|
||||
Boolean indexingPaused, List<IndexRow> indexes, Integer pending, Integer exhausted, Boolean backlogTruncated,
|
||||
Boolean tablesTruncated, Instant lastSuccessfulSync, String lastError, Instant checkedAt, String homeHref)
|
||||
{
|
||||
this(state, clusterReachable, clusterHealth, nodes, activeShards, unassignedShards, null, null, null, indexingPaused, indexes, pending, exhausted,
|
||||
backlogTruncated, tablesTruncated, lastSuccessfulSync, lastError, checkedAt, homeHref);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Yellow only because replicas cannot be placed on a single-node cluster:
|
||||
** one node, every primary active and every unassigned shard a replica.
|
||||
** Such a cluster cannot do better, so it is healthy rather than degraded.
|
||||
*******************************************************************************/
|
||||
public static boolean isSingleNodeReplicaYellow(String clusterHealth, Integer nodes, Integer unassignedReplicaShards)
|
||||
{
|
||||
return ("yellow".equals(clusterHealth) && Integer.valueOf(1).equals(nodes) && unassignedReplicaShards != null && unassignedReplicaShards > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Published wire schema for the same immutable value used by Java hosts.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -126,6 +126,9 @@ public class QuickSearchStatusSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
status.put(name, field(Type.INTEGER, "Cluster health counter; null when unavailable.", true));
|
||||
}
|
||||
status.put("unassignedReplicaShards", field(Type.INTEGER, "Unassigned shards when every primary is active (all are replicas); null when unknown or a primary is not active.", true));
|
||||
status.put("targetReplicas", field(Type.INTEGER, "number_of_replicas Quick Search applies to its index; null when unknown or not managed (Serverless).", true));
|
||||
status.put("replicaSettingsFailed", field(Type.BOOLEAN, "Sizing or applying the index replicas failed at startup; null when not attempted.", true));
|
||||
status.put("indexingPaused", field(Type.BOOLEAN, "Indexing is paused; search can still serve.", true));
|
||||
status.put("indexes", new Schema().withType(Type.ARRAY).withItems(new Schema().withRef("#/components/schemas/QuickSearchIndexStatusV1"))
|
||||
.withDescription("At most 100 persisted index rows, ordered by id."));
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ public class DiagnosticEventRecorder implements AutoCloseable
|
||||
*******************************************************************************/
|
||||
private void insert(QInstance qInstance, String tableName, QuickSearchDiagnosticEvent event)
|
||||
{
|
||||
QSession session = new QSystemUserSession();
|
||||
QSession session = new QSystemUserSession(qInstance);
|
||||
try
|
||||
{
|
||||
QContext.init(qInstance, session);
|
||||
|
||||
@@ -135,6 +135,9 @@ public final class StatusDiagnostics
|
||||
Integer nodes = null;
|
||||
Integer active = null;
|
||||
Integer unassigned = null;
|
||||
Integer unassignedReplicas = null;
|
||||
Integer targetReplicas = null;
|
||||
Boolean replicaSettingsFailed = null;
|
||||
State state = degraded ? State.DEGRADED : State.OK;
|
||||
if(!truncated && !indexes.isEmpty() && indexes.stream().noneMatch(row -> Boolean.TRUE.equals(row.enabled())))
|
||||
{
|
||||
@@ -152,7 +155,15 @@ public final class StatusDiagnostics
|
||||
nodes = number(body, "number_of_nodes");
|
||||
active = number(body, "active_shards");
|
||||
unassigned = number(body, "unassigned_shards");
|
||||
if(IndexValidator.healthLevel(health) != DiagnosticsHtml.Level.OK || body.path("timed_out").asBoolean())
|
||||
unassignedReplicas = unassignedReplicaShards(body, unassigned);
|
||||
var replicaStatus = runtime.getClient().getReplicaStatus();
|
||||
if(replicaStatus != null)
|
||||
{
|
||||
targetReplicas = replicaStatus.targetReplicas();
|
||||
replicaSettingsFailed = replicaStatus.failed();
|
||||
}
|
||||
boolean singleNodeReplicas = QuickSearchStatus.isSingleNodeReplicaYellow(health, nodes, unassignedReplicas);
|
||||
if((IndexValidator.healthLevel(health) != DiagnosticsHtml.Level.OK && !singleNodeReplicas) || body.path("timed_out").asBoolean())
|
||||
{
|
||||
state = "red".equals(health) ? State.DOWN : State.DEGRADED;
|
||||
}
|
||||
@@ -167,7 +178,8 @@ public final class StatusDiagnostics
|
||||
lastError = "OpenSearch health is unavailable; see Quick Search diagnostics.";
|
||||
}
|
||||
}
|
||||
return (new QuickSearchStatus(state, reachable, health, nodes, active, unassigned, paused, indexes, pending, exhausted, backlog.size() > MAX_BACKLOG,
|
||||
return (new QuickSearchStatus(state, reachable, health, nodes, active, unassigned, unassignedReplicas, targetReplicas, replicaSettingsFailed,
|
||||
paused, indexes, pending, exhausted, backlog.size() > MAX_BACKLOG,
|
||||
truncated, lastSync, lastError, Instant.now(), homeHref));
|
||||
}
|
||||
|
||||
@@ -193,6 +205,30 @@ public final class StatusDiagnostics
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** From index-level cluster health: the unassigned shards when nothing is
|
||||
** initializing and every index has all its primaries active (so every
|
||||
** unassigned shard is a replica); null otherwise or when unknown.
|
||||
*******************************************************************************/
|
||||
static Integer unassignedReplicaShards(JsonNode body, Integer unassigned)
|
||||
{
|
||||
if(unassigned == null || !body.path("indices").isObject() || !Integer.valueOf(0).equals(number(body, "initializing_shards")))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(JsonNode index : body.path("indices"))
|
||||
{
|
||||
Integer primaries = number(index, "number_of_shards");
|
||||
if(primaries == null || !primaries.equals(number(index, "active_primary_shards")))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
return (unassigned);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve unknown counters from incomplete cluster responses.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -187,6 +187,11 @@ public class QuickSearchHealthChecks implements HealthCheckSupplier
|
||||
{
|
||||
outcome = HealthCheckOutcome.up("OpenSearch is green");
|
||||
}
|
||||
else if(Boolean.TRUE.equals(status.clusterReachable()) && QuickSearchStatus.isSingleNodeReplicaYellow(status.clusterHealth(), status.nodes(), status.unassignedReplicaShards()))
|
||||
{
|
||||
outcome = HealthCheckOutcome.up("OpenSearch is yellow only because replicas cannot be assigned on a single node");
|
||||
outcome.getDetails().withValue("note", "replicas cannot be assigned on a single-node cluster");
|
||||
}
|
||||
else
|
||||
{
|
||||
outcome = HealthCheckOutcome.degraded("OpenSearch health is yellow or incomplete");
|
||||
@@ -195,6 +200,9 @@ public class QuickSearchHealthChecks implements HealthCheckSupplier
|
||||
detail(outcome, "nodes", status.nodes());
|
||||
detail(outcome, "activeShards", status.activeShards());
|
||||
detail(outcome, "unassignedShards", status.unassignedShards());
|
||||
detail(outcome, "unassignedReplicaShards", status.unassignedReplicaShards());
|
||||
detail(outcome, "targetReplicas", status.targetReplicas());
|
||||
detail(outcome, "replicaSettingsFailed", status.replicaSettingsFailed());
|
||||
return (outcome);
|
||||
}
|
||||
|
||||
|
||||
+153
-3
@@ -34,6 +34,8 @@ import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchAuthMode;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchableTableConfig;
|
||||
import com.kingsrook.qbits.quicksearch.config.QuickSearchFieldRules;
|
||||
@@ -127,11 +129,26 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
private final String indexName;
|
||||
private final int maxBulkRequestBytes;
|
||||
private final ObjectMapper sizingMapper;
|
||||
private final Integer indexReplicas;
|
||||
private final Integer maxAutoReplicas;
|
||||
private final Boolean replicasManaged;
|
||||
|
||||
private volatile Boolean mappingOutdated = false;
|
||||
private volatile Boolean indexCreated = false;
|
||||
private volatile Instant indexCreatedAt;
|
||||
private Boolean closed = false;
|
||||
private volatile ReplicaStatus replicaStatus;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of the latest replica sizing: the target number_of_replicas, the
|
||||
** data nodes it was sized for (null when explicitly configured), how many
|
||||
** physical indexes were changed, and whether applying it failed.
|
||||
*******************************************************************************/
|
||||
public record ReplicaStatus(Integer targetReplicas, Integer dataNodes, Integer updatedIndexes, Boolean failed)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -146,6 +163,9 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
this.indexName = config.getOpensearchIndexName();
|
||||
this.maxBulkRequestBytes = config.getMaxBulkRequestBytes() == null ? 5 * 1024 * 1024 : config.getMaxBulkRequestBytes();
|
||||
this.sizingMapper = new ObjectMapper().registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
this.indexReplicas = config.getIndexReplicas();
|
||||
this.maxAutoReplicas = config.getMaxAutoReplicas() == null ? QuickSearchQBitConfig.DEFAULT_MAX_AUTO_REPLICAS : config.getMaxAutoReplicas();
|
||||
this.replicasManaged = !(config.getEffectiveAuthMode() == QuickSearchAuthMode.AWS_SIGV4 && "aoss".equals(config.getEffectiveAwsServiceName()));
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +192,7 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
LOG.info("OpenSearch index already exists", logPair("indexName", indexName));
|
||||
extendMapping();
|
||||
}
|
||||
reconcileReplicas();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -314,13 +335,21 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
{
|
||||
try
|
||||
{
|
||||
Integer replicas = replicasForNewIndex();
|
||||
client.indices().create(CreateIndexRequest.of(r -> r
|
||||
.index(physicalIndex)
|
||||
.settings(s -> s
|
||||
.analysis(a -> a
|
||||
.settings(s ->
|
||||
{
|
||||
s.analysis(a -> a
|
||||
.analyzer(INDEX_ANALYZER, an -> an.custom(c -> c.tokenizer("standard").filter("lowercase", "edge_ngram_filter")))
|
||||
.analyzer(SEARCH_ANALYZER, an -> an.custom(c -> c.tokenizer("standard").filter("lowercase")))
|
||||
.filter("edge_ngram_filter", f -> f.definition(d -> d.edgeNgram(en -> en.minGram(2).maxGram(20))))))
|
||||
.filter("edge_ngram_filter", f -> f.definition(d -> d.edgeNgram(en -> en.minGram(2).maxGram(20)))));
|
||||
if(replicas != null)
|
||||
{
|
||||
s.numberOfReplicas(replicas);
|
||||
}
|
||||
return (s);
|
||||
})
|
||||
.mappings(buildMapping())));
|
||||
}
|
||||
catch(OpenSearchException | java.io.IOException e)
|
||||
@@ -331,6 +360,127 @@ public class QuickSearchOpenSearchClient implements Closeable
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replicas a new physical index should ask for; null leaves the server
|
||||
** default (Serverless, or the cluster could not be sized; logged).
|
||||
*******************************************************************************/
|
||||
private Integer replicasForNewIndex()
|
||||
{
|
||||
if(!Boolean.TRUE.equals(replicasManaged))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
try
|
||||
{
|
||||
ReplicaStatus target = targetReplicas();
|
||||
replicaStatus = new ReplicaStatus(target.targetReplicas(), target.dataNodes(), 0, false);
|
||||
return (target.targetReplicas());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
replicaStatus = new ReplicaStatus(null, null, 0, true);
|
||||
LOG.warn("Could not size Quick Search index replicas; the new index uses the server default", e, logPair("indexName", indexName));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured indexReplicas, else min(maxAutoReplicas, data nodes - 1)
|
||||
** from cluster health, so a single-node cluster gets 0 and stays green.
|
||||
*******************************************************************************/
|
||||
ReplicaStatus targetReplicas() throws QException
|
||||
{
|
||||
if(indexReplicas != null)
|
||||
{
|
||||
return (new ReplicaStatus(indexReplicas, null, 0, false));
|
||||
}
|
||||
QuickSearchDiagnosticsOperations.RawResponse health = new QuickSearchDiagnosticsOperations(this).raw("GET", "/_cluster/health", null);
|
||||
QuickSearchDiagnosticsOperations.requireOk(health, "Cluster health unavailable");
|
||||
JsonNode dataNodes = health.body() == null ? null : health.body().path("number_of_data_nodes");
|
||||
if(dataNodes == null || !dataNodes.isNumber())
|
||||
{
|
||||
throw (new QException("Cluster health did not report number_of_data_nodes"));
|
||||
}
|
||||
return (new ReplicaStatus(autoReplicas(dataNodes.intValue(), maxAutoReplicas), dataNodes.intValue(), 0, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Automatic replica count: a replica can only be assigned to a different
|
||||
** data node than its primary, so never more than data nodes - 1.
|
||||
*******************************************************************************/
|
||||
static int autoReplicas(int dataNodes, int maxAutoReplicas)
|
||||
{
|
||||
return (Math.max(0, Math.min(maxAutoReplicas, dataNodes - 1)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bring number_of_replicas of the physical index(es) behind the alias to
|
||||
** the target. Idempotent (only differing indexes are updated) and failure
|
||||
** tolerant: a failure is logged and kept in {@link #getReplicaStatus()},
|
||||
** never thrown, so it cannot block startup. Indexes with
|
||||
** auto_expand_replicas set are left to OpenSearch. Null on Serverless.
|
||||
*******************************************************************************/
|
||||
public ReplicaStatus reconcileReplicas()
|
||||
{
|
||||
if(!Boolean.TRUE.equals(replicasManaged))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Integer target = null;
|
||||
Integer nodes = null;
|
||||
try
|
||||
{
|
||||
ReplicaStatus sized = targetReplicas();
|
||||
target = sized.targetReplicas();
|
||||
nodes = sized.dataNodes();
|
||||
QuickSearchDiagnosticsOperations operations = new QuickSearchDiagnosticsOperations(this);
|
||||
QuickSearchDiagnosticsOperations.RawResponse settings = operations.raw("GET", "/" + QuickSearchDiagnosticsOperations.encode(indexName) + "/_settings", null);
|
||||
QuickSearchDiagnosticsOperations.requireOk(settings, "Index settings unavailable");
|
||||
int updated = 0;
|
||||
for(Map.Entry<String, JsonNode> entry : settings.body().properties())
|
||||
{
|
||||
JsonNode index = entry.getValue().path("settings").path("index");
|
||||
String current = index.path("number_of_replicas").asText(null);
|
||||
String autoExpand = index.path("auto_expand_replicas").asText("false");
|
||||
if(!"false".equalsIgnoreCase(autoExpand) || String.valueOf(target).equals(current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QuickSearchDiagnosticsOperations.RawResponse put = operations.raw("PUT", "/" + QuickSearchDiagnosticsOperations.encode(entry.getKey()) + "/_settings",
|
||||
"{\"index\":{\"number_of_replicas\":" + target + "}}");
|
||||
QuickSearchDiagnosticsOperations.requireOk(put, "Updating number_of_replicas failed");
|
||||
updated++;
|
||||
LOG.info("Updated Quick Search index replicas", logPair("physicalIndex", entry.getKey()), logPair("from", current),
|
||||
logPair("to", target), logPair("dataNodes", nodes));
|
||||
}
|
||||
replicaStatus = new ReplicaStatus(target, nodes, updated, false);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not reconcile Quick Search index replicas; continuing", e, logPair("indexName", indexName), logPair("targetReplicas", target));
|
||||
replicaStatus = new ReplicaStatus(target, nodes, 0, true);
|
||||
}
|
||||
return (replicaStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The latest replica sizing outcome; null before any (or on Serverless).
|
||||
*******************************************************************************/
|
||||
public ReplicaStatus getReplicaStatus()
|
||||
{
|
||||
return (replicaStatus);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -154,6 +154,11 @@ components:
|
||||
\ backlog rows."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
replicaSettingsFailed:
|
||||
description: "Sizing or applying the index replicas failed at startup; null\
|
||||
\ when not attempted."
|
||||
nullable: true
|
||||
type: "boolean"
|
||||
state:
|
||||
description: "Overall availability; incomplete diagnostics are DEGRADED."
|
||||
enum:
|
||||
@@ -167,6 +172,16 @@ components:
|
||||
description: "More than 100 index rows exist."
|
||||
nullable: false
|
||||
type: "boolean"
|
||||
targetReplicas:
|
||||
description: "number_of_replicas Quick Search applies to its index; null\
|
||||
\ when unknown or not managed (Serverless)."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
unassignedReplicaShards:
|
||||
description: "Unassigned shards when every primary is active (all are replicas);\
|
||||
\ null when unknown or a primary is not active."
|
||||
nullable: true
|
||||
type: "integer"
|
||||
unassignedShards:
|
||||
description: "Cluster health counter; null when unavailable."
|
||||
nullable: true
|
||||
|
||||
@@ -48,6 +48,7 @@ import com.kingsrook.qbits.quicksearch.annotations.QuickSearchField;
|
||||
import com.kingsrook.qbits.quicksearch.annotations.QuickSearchable;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.BulkIndexResult;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.OpenSearchDocument;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchDiagnosticsOperations;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient;
|
||||
import com.kingsrook.qbits.quicksearch.processes.FullReindexStep;
|
||||
import com.kingsrook.qbits.quicksearch.processes.ReconcileIndexStep;
|
||||
@@ -218,6 +219,14 @@ class OpenSearchIntegrationTest
|
||||
assertThat(physicalIndexes.iterator().next()).startsWith(INDEX_NAME + "-v" + QuickSearchOpenSearchClient.MAPPING_VERSION + "-");
|
||||
assertThat(client().isMappingOutdated()).isFalse();
|
||||
assertThat(client().isHealthy()).isTrue();
|
||||
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// zero config on a single node: no replica, so the index is green //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
String physicalIndex = physicalIndexes.iterator().next();
|
||||
QuickSearchDiagnosticsOperations operations = new QuickSearchDiagnosticsOperations(client());
|
||||
assertThat(operations.settings().body().path(physicalIndex).path("settings").path("index").path("number_of_replicas").asText()).isEqualTo("0");
|
||||
assertThat(operations.clusterHealth().body().path("status").asText()).isEqualTo("green");
|
||||
}
|
||||
|
||||
|
||||
@@ -520,6 +529,27 @@ class OpenSearchIntegrationTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test 12: an index left with a replica it cannot place on one node (yellow)
|
||||
** is brought back to 0 replicas (green) by the startup reconcile, once.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@Order(12)
|
||||
void testReconcileReplicas_singleNodeDropsUnassignableReplica() throws Exception
|
||||
{
|
||||
String physicalIndex = client().getPhysicalIndexes().iterator().next();
|
||||
QuickSearchDiagnosticsOperations operations = new QuickSearchDiagnosticsOperations(client());
|
||||
assertThat(operations.raw("PUT", "/" + physicalIndex + "/_settings", "{\"index\":{\"number_of_replicas\":1}}").isOk()).isTrue();
|
||||
assertThat(operations.clusterHealth().body().path("status").asText()).isEqualTo("yellow");
|
||||
|
||||
assertThat(client().reconcileReplicas()).isEqualTo(new QuickSearchOpenSearchClient.ReplicaStatus(0, 1, 1, false));
|
||||
assertThat(operations.settings().body().path(physicalIndex).path("settings").path("index").path("number_of_replicas").asText()).isEqualTo("0");
|
||||
assertThat(operations.clusterHealth().body().path("status").asText()).isEqualTo("green");
|
||||
assertThat(client().reconcileReplicas()).isEqualTo(new QuickSearchOpenSearchClient.ReplicaStatus(0, 1, 0, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuild one table in place and refresh so its documents are searchable.
|
||||
*******************************************************************************/
|
||||
|
||||
+5
-1
@@ -348,7 +348,11 @@ class QuickSearchQBitConfigValidationTest
|
||||
.withBasepullOverlapSeconds(-5)
|
||||
.withRunHistoryRetentionDays(0)
|
||||
.withFullReindexStaleMinutes(0)
|
||||
.withMaxFieldLength(0));
|
||||
.withMaxFieldLength(0)
|
||||
.withIndexReplicas(-1)
|
||||
.withMaxAutoReplicas(-1));
|
||||
assertThat(errors).anyMatch(e -> e.startsWith("indexReplicas"));
|
||||
assertThat(errors).anyMatch(e -> e.startsWith("maxAutoReplicas"));
|
||||
assertThat(errors).anyMatch(e -> e.startsWith("connectTimeoutMillis"));
|
||||
assertThat(errors).anyMatch(e -> e.startsWith("responseTimeoutMillis"));
|
||||
assertThat(errors).anyMatch(e -> e.startsWith("maxConnections"));
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndex;
|
||||
import com.kingsrook.qbits.quicksearch.model.QuickSearchIndexField;
|
||||
import com.kingsrook.qbits.quicksearch.publisher.IndexEventPublisher;
|
||||
@@ -223,4 +224,31 @@ class QuickSearchRuntimeTest extends BaseQuickSearchTest
|
||||
failFast.close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Priming at startup without a QContext builds the system session with the
|
||||
** start instance (so it has the all-access keys), not from the empty context.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void primeConfigWithoutAContextGivesTheSystemSessionItsInstance()
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
QuickSearchRuntime runtime = new QuickSearchRuntime(new QuickSearchQBitConfig().withBackendName(TEST_BACKEND_NAME).withOpensearchHost("127.0.0.1").withOpensearchPort(1).withOpensearchIndexName("t"));
|
||||
QCollectingLogger collectingLogger = QLogger.activateCollectingLoggerForClass(QSystemUserSession.class);
|
||||
try
|
||||
{
|
||||
runtime.setStartInstance(qInstance);
|
||||
QContext.clear();
|
||||
runtime.primeConfig();
|
||||
}
|
||||
finally
|
||||
{
|
||||
QLogger.deactivateCollectingLoggerForClass(QSystemUserSession.class);
|
||||
QContext.init(qInstance, new QSystemUserSession(qInstance));
|
||||
runtime.close();
|
||||
}
|
||||
assertThat(collectingLogger.getCollectedMessages()).noneSatisfy(m -> assertThat(m.getMessage()).contains("QInstance was not supplied"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -80,6 +80,7 @@ class QuickSearchStatusTest
|
||||
private QuickSearchQBitConfig config;
|
||||
private QInstance instance;
|
||||
private volatile String health = "green";
|
||||
private volatile String extraHealth = ",\"number_of_nodes\":1,\"active_shards\":1,\"unassigned_shards\":0";
|
||||
private volatile int requests;
|
||||
private CountDownLatch blocked;
|
||||
|
||||
@@ -106,7 +107,7 @@ class QuickSearchStatusTest
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
byte[] body = ("{\"status\":\"" + health + "\",\"number_of_nodes\":1,\"active_shards\":1,\"unassigned_shards\":0}").getBytes(StandardCharsets.UTF_8);
|
||||
byte[] body = ("{\"status\":\"" + health + "\"" + extraHealth + "}").getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(200, body.length);
|
||||
exchange.getResponseBody().write(body);
|
||||
@@ -214,6 +215,36 @@ class QuickSearchStatusTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Yellow from replicas a single node cannot hold is OK; an inactive primary
|
||||
** or a second node keeps it DEGRADED.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void singleNodeReplicaYellow() throws Exception
|
||||
{
|
||||
health = "yellow";
|
||||
String index = ",\"indices\":{\"status-test-v2-1\":{\"number_of_shards\":1,\"active_primary_shards\":%d}}";
|
||||
extraHealth = ",\"number_of_nodes\":1,\"active_shards\":1,\"unassigned_shards\":1,\"initializing_shards\":0" + index.formatted(1);
|
||||
QuickSearchStatus single = StatusDiagnostics.read(config.getRuntime());
|
||||
assertThat(single.state()).isEqualTo(QuickSearchStatus.State.OK);
|
||||
assertThat(single.unassignedReplicaShards()).isEqualTo(1);
|
||||
|
||||
extraHealth = ",\"number_of_nodes\":1,\"active_shards\":0,\"unassigned_shards\":2,\"initializing_shards\":0" + index.formatted(0);
|
||||
QuickSearchStatus primaryMissing = StatusDiagnostics.read(config.getRuntime());
|
||||
assertThat(primaryMissing.state()).isEqualTo(QuickSearchStatus.State.DEGRADED);
|
||||
assertThat(primaryMissing.unassignedReplicaShards()).isNull();
|
||||
|
||||
extraHealth = ",\"number_of_nodes\":1,\"active_shards\":0,\"unassigned_shards\":1,\"initializing_shards\":1" + index.formatted(1);
|
||||
assertThat(StatusDiagnostics.read(config.getRuntime()).unassignedReplicaShards()).isNull();
|
||||
|
||||
extraHealth = ",\"number_of_nodes\":2,\"active_shards\":1,\"unassigned_shards\":1,\"initializing_shards\":0" + index.formatted(1);
|
||||
QuickSearchStatus multi = StatusDiagnostics.read(config.getRuntime());
|
||||
assertThat(multi.state()).isEqualTo(QuickSearchStatus.State.DEGRADED);
|
||||
assertThat(multi.unassignedReplicaShards()).isEqualTo(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A blocked remote call has bounded caller latency and cannot grow a queue.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -168,6 +168,28 @@ class QuickSearchHealthChecksTest extends BaseQuickSearchTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Yellow is healthy only on one node with every unassigned shard a replica.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({"yellow,1,1,UP", "yellow,1,0,DEGRADED", "yellow,1,,DEGRADED", "yellow,2,1,DEGRADED", "yellow,,1,DEGRADED",
|
||||
"green,1,0,UP", "red,1,1,DOWN"})
|
||||
void singleNodeReplicas(String color, Integer nodes, Integer unassignedReplicas, HealthStatus expected) throws Exception
|
||||
{
|
||||
when(cache.get(eq(runtime), any(Instant.class))).thenReturn(new QuickSearchStatus(State.OK, true, color, nodes, 1, 1, unassignedReplicas, 0, false,
|
||||
false, List.of(row(true, "ACTIVE", 12L, 12L, null, 0)), 0, 0, false, false, null, SENTINEL, Instant.now(), "/unused"));
|
||||
HealthCheckOutcome outcome = run("opensearch");
|
||||
assertThat(outcome.getStatus()).isEqualTo(expected);
|
||||
assertThat(outcome.getDetails().toJson()).contains("\"targetReplicas\":0", "\"replicaSettingsFailed\":false");
|
||||
if(expected == HealthStatus.UP && "yellow".equals(color))
|
||||
{
|
||||
assertThat(outcome.getDetails().toJson()).contains("\"unassignedReplicaShards\":1", "replicas cannot be assigned on a single-node cluster");
|
||||
}
|
||||
assertClean(outcome);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Boundary values, exhausted events, pause, truncation and missing measurements.
|
||||
*******************************************************************************/
|
||||
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* Copyright 2024 Kingsrook, LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.quicksearch.opensearch;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchAuthMode;
|
||||
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
|
||||
import com.kingsrook.qbits.quicksearch.opensearch.QuickSearchOpenSearchClient.ReplicaStatus;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replica sizing and reconcile against a stub OpenSearch HTTP server.
|
||||
*******************************************************************************/
|
||||
class QuickSearchReplicasTest
|
||||
{
|
||||
private HttpServer server;
|
||||
private List<String> requests;
|
||||
private List<String> bodies;
|
||||
private Integer dataNodes;
|
||||
private String settings;
|
||||
private Integer settingsStatus;
|
||||
private Integer putStatus;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stub: _cluster/health reports dataNodes, GET _settings returns settings,
|
||||
** PUT _settings returns putStatus; every request is recorded.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void setUp() throws IOException
|
||||
{
|
||||
requests = new ArrayList<>();
|
||||
bodies = new ArrayList<>();
|
||||
dataNodes = 1;
|
||||
settingsStatus = 200;
|
||||
putStatus = 200;
|
||||
settings = settingsBody("1", null);
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", this::handle);
|
||||
server.start();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void tearDown()
|
||||
{
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private void handle(HttpExchange exchange) throws IOException
|
||||
{
|
||||
String request = exchange.getRequestMethod() + " " + exchange.getRequestURI().getPath();
|
||||
requests.add(request);
|
||||
bodies.add(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
int status = 200;
|
||||
String body = "{}";
|
||||
if(request.equals("GET /_cluster/health"))
|
||||
{
|
||||
body = dataNodes == null ? "{\"status\":\"green\"}" : "{\"status\":\"yellow\",\"number_of_data_nodes\":" + dataNodes + "}";
|
||||
}
|
||||
else if(request.startsWith("GET ") && request.endsWith("/_settings"))
|
||||
{
|
||||
status = settingsStatus;
|
||||
body = status == 200 ? settings : "{\"error\":{\"reason\":\"nope\"}}";
|
||||
}
|
||||
else if(request.startsWith("PUT ") && request.endsWith("/_settings"))
|
||||
{
|
||||
status = putStatus;
|
||||
body = status == 200 ? "{\"acknowledged\":true}" : "{\"error\":{\"reason\":\"blocked\"}}";
|
||||
}
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json");
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String settingsBody(String replicas, String autoExpand)
|
||||
{
|
||||
return ("{\"alias-v2-1\":{\"settings\":{\"index\":{\"number_of_shards\":\"1\",\"number_of_replicas\":\"" + replicas + "\""
|
||||
+ (autoExpand == null ? "" : ",\"auto_expand_replicas\":\"" + autoExpand + "\"") + "}}}}");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private QuickSearchQBitConfig config()
|
||||
{
|
||||
return (new QuickSearchQBitConfig().withOpensearchUrl("http://127.0.0.1:" + server.getAddress().getPort()).withOpensearchIndexName("alias"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Automatic sizing never exceeds data nodes - 1 nor maxAutoReplicas.
|
||||
*******************************************************************************/
|
||||
@ParameterizedTest
|
||||
@CsvSource({"1,1,0", "2,1,1", "3,1,1", "3,2,2", "5,2,2", "0,1,0", "4,0,0"})
|
||||
void autoReplicas(int nodes, int max, int expected)
|
||||
{
|
||||
assertThat(QuickSearchOpenSearchClient.autoReplicas(nodes, max)).isEqualTo(expected);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Zero config on one node: the replica is dropped, once.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void singleNodeDropsReplicaIdempotently() throws Exception
|
||||
{
|
||||
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config()))
|
||||
{
|
||||
ReplicaStatus status = client.reconcileReplicas();
|
||||
assertThat(status).isEqualTo(new ReplicaStatus(0, 1, 1, false));
|
||||
assertThat(client.getReplicaStatus()).isEqualTo(status);
|
||||
assertThat(requests).contains("PUT /alias-v2-1/_settings");
|
||||
assertThat(bodies.get(requests.indexOf("PUT /alias-v2-1/_settings"))).isEqualTo("{\"index\":{\"number_of_replicas\":0}}");
|
||||
|
||||
settings = settingsBody("0", null);
|
||||
requests.clear();
|
||||
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(0, 1, 0, false));
|
||||
assertThat(requests).noneMatch(request -> request.startsWith("PUT"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Multi-node clusters keep (or get) one replica by default.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void multiNodeKeepsOneReplica() throws Exception
|
||||
{
|
||||
dataNodes = 3;
|
||||
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config()))
|
||||
{
|
||||
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(1, 3, 0, false));
|
||||
assertThat(requests).noneMatch(request -> request.startsWith("PUT"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An explicit indexReplicas wins and does not need cluster health.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void explicitReplicasOverrideAuto() throws Exception
|
||||
{
|
||||
settings = settingsBody("0", null);
|
||||
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config().withIndexReplicas(2)))
|
||||
{
|
||||
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(2, null, 1, false));
|
||||
assertThat(requests).doesNotContain("GET /_cluster/health");
|
||||
assertThat(bodies.get(requests.indexOf("PUT /alias-v2-1/_settings"))).isEqualTo("{\"index\":{\"number_of_replicas\":2}}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An operator's auto_expand_replicas is left alone.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void autoExpandIsRespected() throws Exception
|
||||
{
|
||||
settings = settingsBody("1", "0-1");
|
||||
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config()))
|
||||
{
|
||||
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(0, 1, 0, false));
|
||||
assertThat(requests).noneMatch(request -> request.startsWith("PUT"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failures are recorded, never thrown.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void failuresAreToleratedAndRecorded() throws Exception
|
||||
{
|
||||
putStatus = 403;
|
||||
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config()))
|
||||
{
|
||||
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(0, 1, 0, true));
|
||||
|
||||
settingsStatus = 500;
|
||||
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(0, 1, 0, true));
|
||||
|
||||
dataNodes = null;
|
||||
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(null, null, 0, true));
|
||||
assertThat(client.getReplicaStatus().failed()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unreachable cluster is a recorded failure too.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unreachableIsRecorded() throws Exception
|
||||
{
|
||||
QuickSearchQBitConfig config = config();
|
||||
server.stop(0);
|
||||
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config.withConnectTimeoutMillis(500)))
|
||||
{
|
||||
assertThat(client.reconcileReplicas()).isEqualTo(new ReplicaStatus(null, null, 0, true));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** OpenSearch Serverless manages replicas itself: nothing is sent.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void serverlessIsNotManaged() throws Exception
|
||||
{
|
||||
QuickSearchQBitConfig config = config().withAuthMode(QuickSearchAuthMode.AWS_SIGV4).withAwsServiceName("aoss").withAwsRegion("us-east-1");
|
||||
try(QuickSearchOpenSearchClient client = new QuickSearchOpenSearchClient(config))
|
||||
{
|
||||
assertThat(client.reconcileReplicas()).isNull();
|
||||
assertThat(client.getReplicaStatus()).isNull();
|
||||
assertThat(requests).isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user