* fix(#24): recover a stuck REBUILDING status after a killed reindex A full reindex now refuses to start while another is alive, judged by a per-page heartbeat on its FULL_REINDEX run records. A run stale for fullReindexStaleMinutes (default 30) is recovered by the next basepull or full reindex: its records become FAILED, its tables ACTIVE, and its captured deletes PENDING for basepull. Closes #24 * fix(#24): keep a slow captured-delete drain from touching a newer run's rows The drain now heartbeats every batch, reads only rows up to the newest one at its start and stops once a recovery takes the run over; recovery marks dead runs FAILED last, basepull skips replayed deletes of re-created records, and the heartbeat is best effort and FULL_REINDEX only. Refs #24 * fix(#24): fail recovery when a dead run cannot be marked FAILED Recovery now writes FAILED with an update that must succeed for every stale run record, so a new full reindex refuses to start rather than run beside a misjudged live one. Tests pin each heartbeat point and the unreadable-takeover branch. Refs #24
430 lines
18 KiB
Java
430 lines
18 KiB
Java
/*
|
|
* 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;
|
|
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.quartz.QuartzSchedulerMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
|
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
|
import org.junit.jupiter.api.Test;
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
|
|
/*******************************************************************************
|
|
** Validation rules added for 1.0: URLs, auth modes, TLS, index names, field
|
|
** existence, scheduling, secrets handling.
|
|
*******************************************************************************/
|
|
class QuickSearchQBitConfigValidationTest
|
|
{
|
|
|
|
/*******************************************************************************
|
|
**
|
|
*******************************************************************************/
|
|
private QInstance instanceWithBackendAndCustomer()
|
|
{
|
|
QInstance qInstance = new QInstance();
|
|
qInstance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
|
qInstance.addTable(new QTableMetaData()
|
|
.withName("customer")
|
|
.withBackendName("memory")
|
|
.withPrimaryKeyField("id")
|
|
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
|
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
|
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME)));
|
|
return (qInstance);
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
**
|
|
*******************************************************************************/
|
|
private QuickSearchQBitConfig baseConfig()
|
|
{
|
|
return (new QuickSearchQBitConfig()
|
|
.withBackendName("memory")
|
|
.withOpensearchHost("localhost")
|
|
.withOpensearchPort(9200)
|
|
.withOpensearchIndexName("app-search")
|
|
.withSearchableTable("customer", List.of(new SearchableFieldConfig("name"))));
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
**
|
|
*******************************************************************************/
|
|
private List<String> validate(QuickSearchQBitConfig config)
|
|
{
|
|
List<String> errors = new ArrayList<>();
|
|
config.validate(instanceWithBackendAndCustomer(), errors);
|
|
return (errors);
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testUrl_replacesHostPortAndScheme()
|
|
{
|
|
QuickSearchQBitConfig config = baseConfig().withOpensearchHost(null).withOpensearchPort(null).withOpensearchUrl("https://search.example.com");
|
|
assertThat(validate(config)).isEmpty();
|
|
assertThat(config.getEffectiveScheme()).isEqualTo("https");
|
|
assertThat(config.getEffectiveHost()).isEqualTo("search.example.com");
|
|
assertThat(config.getEffectivePort()).isEqualTo(443);
|
|
|
|
config.withOpensearchUrl("http://os.internal:9201");
|
|
assertThat(config.getEffectivePort()).isEqualTo(9201);
|
|
assertThat(config.getEffectiveScheme()).isEqualTo("http");
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testUrl_invalid_addsError()
|
|
{
|
|
assertThat(validate(baseConfig().withOpensearchUrl("ftp://nope"))).anyMatch(e -> e.contains("http:// or https://"));
|
|
assertThat(validate(baseConfig().withOpensearchUrl("https://"))).anyMatch(e -> e.contains("host"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testUrl_withUserInfo_isRejectedWithoutEchoingIt()
|
|
{
|
|
List<String> errors = validate(baseConfig().withOpensearchHost(null).withOpensearchPort(null).withOpensearchUrl("https://admin:s3cret@search.example.com"));
|
|
assertThat(errors).anyMatch(e -> e.contains("opensearchUrl must not contain credentials"));
|
|
assertThat(errors).noneMatch(e -> e.contains("s3cret") || e.contains("admin"));
|
|
|
|
assertThat(validate(baseConfig().withOpensearchUrl("https://admin@search.example.com"))).anyMatch(e -> e.contains("opensearchUrl must not contain credentials"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testAuthMode_inferredFromCredentials()
|
|
{
|
|
assertThat(baseConfig().getEffectiveAuthMode()).isEqualTo(QuickSearchAuthMode.NONE);
|
|
assertThat(baseConfig().withOpensearchUsername("u").withOpensearchPassword("p").getEffectiveAuthMode()).isEqualTo(QuickSearchAuthMode.BASIC);
|
|
assertThat(baseConfig().withAuthMode(QuickSearchAuthMode.AWS_SIGV4).getEffectiveAuthMode()).isEqualTo(QuickSearchAuthMode.AWS_SIGV4);
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testBasic_overPlainHttp_isRefusedUnlessAllowed()
|
|
{
|
|
QuickSearchQBitConfig config = baseConfig().withOpensearchUsername("u").withOpensearchPassword("p");
|
|
assertThat(validate(config)).anyMatch(e -> e.contains("plain http"));
|
|
|
|
assertThat(validate(config.withAllowPlaintextCredentials(true))).noneMatch(e -> e.contains("plain http"));
|
|
assertThat(validate(baseConfig().withOpensearchUsername("u").withOpensearchPassword("p").withUseSsl(true))).noneMatch(e -> e.contains("plain http"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testBasic_explicitWithoutCredentials_addsError()
|
|
{
|
|
assertThat(validate(baseConfig().withAuthMode(QuickSearchAuthMode.BASIC).withUseSsl(true))).anyMatch(e -> e.contains("required for authMode BASIC"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testNone_withCredentials_addsError()
|
|
{
|
|
assertThat(validate(baseConfig().withAuthMode(QuickSearchAuthMode.NONE).withOpensearchUsername("u").withOpensearchPassword("p"))).anyMatch(e -> e.contains("authMode is NONE"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testSigV4_requirementsAndDefaults()
|
|
{
|
|
QuickSearchQBitConfig config = baseConfig().withAuthMode(QuickSearchAuthMode.AWS_SIGV4).withAwsRegion("us-east-1");
|
|
List<String> errors = validate(config);
|
|
assertThat(errors).anyMatch(e -> e.contains("requires an https connection"));
|
|
assertThat(config.getEffectiveAwsServiceName()).isEqualTo("es");
|
|
|
|
errors = validate(config.withUseSsl(true).withOpensearchUsername("u").withOpensearchPassword("p"));
|
|
assertThat(errors).anyMatch(e -> e.contains("not used with authMode AWS_SIGV4"));
|
|
|
|
errors = validate(baseConfig().withAuthMode(QuickSearchAuthMode.AWS_SIGV4).withUseSsl(true).withAwsRegion("us-east-1").withAwsServiceName("bogus"));
|
|
assertThat(errors).anyMatch(e -> e.contains("es or aoss"));
|
|
|
|
errors = validate(baseConfig().withAuthMode(QuickSearchAuthMode.AWS_SIGV4).withUseSsl(true).withAwsRegion("us-east-1"));
|
|
assertThat(errors).isEmpty();
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testTls_insecureSkipVerify_onlyForLoopbackUnlessOverridden()
|
|
{
|
|
QuickSearchQBitConfig loopback = baseConfig().withTls(new QuickSearchTlsConfig().withInsecureSkipVerify(true));
|
|
assertThat(validate(loopback)).isEmpty();
|
|
|
|
QuickSearchQBitConfig remote = baseConfig().withOpensearchHost("search.example.com").withTls(new QuickSearchTlsConfig().withInsecureSkipVerify(true));
|
|
assertThat(validate(remote)).anyMatch(e -> e.contains("insecureSkipVerify"));
|
|
|
|
remote.getTls().withAllowInsecureInProduction(true);
|
|
assertThat(validate(remote)).noneMatch(e -> e.contains("insecureSkipVerify"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testTls_caAndTrustStoreTogether_addsError()
|
|
{
|
|
QuickSearchQBitConfig config = baseConfig().withTls(new QuickSearchTlsConfig().withCaCertificatePath("/ca.pem").withTrustStorePath("/ts.p12"));
|
|
assertThat(validate(config)).anyMatch(e -> e.contains("not both"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testIndexName_rules()
|
|
{
|
|
assertThat(QuickSearchQBitConfig.isValidIndexName("app-search_v2.1+x")).isTrue();
|
|
assertThat(QuickSearchQBitConfig.isValidIndexName("App")).isFalse();
|
|
assertThat(QuickSearchQBitConfig.isValidIndexName("_hidden")).isFalse();
|
|
assertThat(QuickSearchQBitConfig.isValidIndexName("has space")).isFalse();
|
|
assertThat(QuickSearchQBitConfig.isValidIndexName("a/b")).isFalse();
|
|
assertThat(QuickSearchQBitConfig.isValidIndexName("..")).isFalse();
|
|
assertThat(QuickSearchQBitConfig.isValidIndexName("a".repeat(256))).isFalse();
|
|
|
|
assertThat(validate(baseConfig().withOpensearchIndexName("Bad Name"))).anyMatch(e -> e.contains("opensearchIndexName must be lowercase"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testFieldExistence_checkedAgainstInstanceTables()
|
|
{
|
|
QuickSearchQBitConfig config = baseConfig().withSearchableTables(null)
|
|
.withSearchableTable(new SearchableTableConfig("customer", List.of(new SearchableFieldConfig("name"), new SearchableFieldConfig("nope"))).withBasepullTimestampField("updatedAt"));
|
|
List<String> errors = validate(config);
|
|
assertThat(errors).anyMatch(e -> e.contains("[nope] does not exist on table [customer]"));
|
|
assertThat(errors).anyMatch(e -> e.contains("basepullTimestampField [updatedAt] does not exist"));
|
|
|
|
QuickSearchQBitConfig ok = baseConfig().withSearchableTables(null)
|
|
.withSearchableTable(new SearchableTableConfig("customer", List.of(new SearchableFieldConfig("name"))).withBasepullTimestampField(null));
|
|
assertThat(validate(ok)).isEmpty();
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testTableName_withColon_addsError()
|
|
{
|
|
assertThat(validate(baseConfig().withSearchableTable("weird:name", List.of(new SearchableFieldConfig("x"))))).anyMatch(e -> e.contains("must not contain ':'"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testScheduling_schedulerMustExist_andCronNeedsScheduler()
|
|
{
|
|
QInstance qInstance = instanceWithBackendAndCustomer();
|
|
qInstance.addScheduler(new QuartzSchedulerMetaData().withName("sched"));
|
|
|
|
List<String> errors = new ArrayList<>();
|
|
baseConfig().withSchedulerName("missing").validate(qInstance, errors);
|
|
assertThat(errors).anyMatch(e -> e.contains("Scheduler not found: missing"));
|
|
|
|
errors = new ArrayList<>();
|
|
baseConfig().withSchedulerName("sched").withReconcileCronExpression("0 0 3 * * ?").withReconcileCronTimeZoneId("America/Chicago").validate(qInstance, errors);
|
|
assertThat(errors).isEmpty();
|
|
|
|
errors = new ArrayList<>();
|
|
baseConfig().withReconcileCronExpression("0 0 3 * * ?").withReconcileCronTimeZoneId("UTC").validate(qInstance, errors);
|
|
assertThat(errors).anyMatch(e -> e.contains("requires schedulerName"));
|
|
|
|
///////////////////////////////////////////////////////////////////////
|
|
// an instance without schedulers cannot be checked; no error raised //
|
|
///////////////////////////////////////////////////////////////////////
|
|
assertThat(validate(baseConfig().withSchedulerName("anything"))).isEmpty();
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** QQQ's instance validator rejects a cron schedule without a time zone and
|
|
** has no default, so the QBit rejects it at produce time, naming its fields.
|
|
*******************************************************************************/
|
|
@Test
|
|
void testScheduling_cronWithoutTimeZone_addsError()
|
|
{
|
|
QInstance qInstance = instanceWithBackendAndCustomer();
|
|
qInstance.addScheduler(new QuartzSchedulerMetaData().withName("sched"));
|
|
|
|
List<String> errors = new ArrayList<>();
|
|
baseConfig().withSchedulerName("sched").withReconcileCronExpression("0 0 3 * * ?").validate(qInstance, errors);
|
|
assertThat(errors).containsExactly("reconcileCronExpression requires reconcileCronTimeZoneId (a time zone ID such as UTC or America/Chicago)");
|
|
|
|
///////////////////////////////////////////////////////////////////
|
|
// without a scheduler in the instance the rule still applies //
|
|
///////////////////////////////////////////////////////////////////
|
|
assertThat(validate(baseConfig().withSchedulerName("sched").withReconcileCronExpression("0 0 3 * * ?").withReconcileCronTimeZoneId(" ")))
|
|
.anyMatch(e -> e.startsWith("reconcileCronExpression requires reconcileCronTimeZoneId"));
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** The cron expression and the zone id are checked the way QQQ's instance
|
|
** validator checks them: Quartz cron syntax and a known TimeZone id.
|
|
*******************************************************************************/
|
|
@Test
|
|
void testScheduling_invalidCronOrTimeZone_addsErrors()
|
|
{
|
|
List<String> errors = validate(baseConfig().withSchedulerName("sched").withReconcileCronExpression("0 0 3 * *").withReconcileCronTimeZoneId("UTC"));
|
|
assertThat(errors).singleElement().satisfies(e -> assertThat(e).startsWith("reconcileCronExpression [0 0 3 * *] is not a valid Quartz cron expression"));
|
|
|
|
errors = validate(baseConfig().withSchedulerName("sched").withReconcileCronExpression("0 0 3 * * ?").withReconcileCronTimeZoneId("Mars/Olympus_Mons"));
|
|
assertThat(errors).containsExactly("reconcileCronTimeZoneId [Mars/Olympus_Mons] is not a recognized time zone ID");
|
|
|
|
errors = validate(baseConfig().withSchedulerName("sched").withReconcileCronExpression("0 0 3 * * ?").withReconcileCronTimeZoneId("UTC"));
|
|
assertThat(errors).isEmpty();
|
|
}
|
|
|
|
|
|
|
|
/*******************************************************************************
|
|
** QQQ's simple scheduler only runs fixed intervals; a cron schedule on it
|
|
** fails when the schedule manager starts.
|
|
*******************************************************************************/
|
|
@Test
|
|
void testScheduling_cronOnSchedulerWithoutCronSupport_addsError()
|
|
{
|
|
QInstance qInstance = instanceWithBackendAndCustomer();
|
|
qInstance.addScheduler(new SimpleSchedulerMetaData().withName("simple"));
|
|
|
|
List<String> errors = new ArrayList<>();
|
|
baseConfig().withSchedulerName("simple").withReconcileCronExpression("0 0 3 * * ?").withReconcileCronTimeZoneId("UTC").validate(qInstance, errors);
|
|
assertThat(errors).containsExactly("reconcileCronExpression requires a scheduler that supports cron schedules, such as a QuartzSchedulerMetaData; scheduler [simple] is of type simple");
|
|
|
|
////////////////////////////////////////////////
|
|
// basepull's fixed interval runs on either //
|
|
////////////////////////////////////////////////
|
|
errors = new ArrayList<>();
|
|
baseConfig().withSchedulerName("simple").validate(qInstance, errors);
|
|
assertThat(errors).isEmpty();
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testNumbers_mustBePositive()
|
|
{
|
|
List<String> errors = validate(baseConfig()
|
|
.withConnectTimeoutMillis(0)
|
|
.withResponseTimeoutMillis(-1)
|
|
.withMaxConnections(0)
|
|
.withMaxSearchLimit(0)
|
|
.withRecordSearchTimeoutMillis(0)
|
|
.withBasepullRepeatSeconds(0)
|
|
.withBasepullOverlapSeconds(-5)
|
|
.withRunHistoryRetentionDays(0)
|
|
.withFullReindexStaleMinutes(0)
|
|
.withMaxFieldLength(0));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("connectTimeoutMillis"));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("responseTimeoutMillis"));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("maxConnections"));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("maxSearchLimit"));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("recordSearchTimeoutMillis"));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("basepullRepeatSeconds"));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("basepullOverlapSeconds"));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("runHistoryRetentionDays"));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("fullReindexStaleMinutes"));
|
|
assertThat(errors).anyMatch(e -> e.startsWith("maxFieldLength"));
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testFullReindexStaleMinutes_nullIsValid_andMeansTheDefault()
|
|
{
|
|
QuickSearchQBitConfig config = baseConfig().withFullReindexStaleMinutes(null);
|
|
assertThat(validate(config)).isEmpty();
|
|
assertThat(config.getEffectiveFullReindexStaleMinutes()).isEqualTo(QuickSearchQBitConfig.DEFAULT_FULL_REINDEX_STALE_MINUTES);
|
|
assertThat(config.withFullReindexStaleMinutes(5).getEffectiveFullReindexStaleMinutes()).isEqualTo(5);
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testValidate_nullInstance_isSafe()
|
|
{
|
|
List<String> errors = new ArrayList<>();
|
|
baseConfig().validate(null, errors);
|
|
assertThat(errors).isEmpty();
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testDeprecatedScheduledFlag_setsBothNewFlags()
|
|
{
|
|
@SuppressWarnings("deprecation")
|
|
QuickSearchQBitConfig config = baseConfig().withEnableScheduledProcesses(false);
|
|
assertThat(config.getEnableBasepullProcess()).isFalse();
|
|
assertThat(config.getEnableMaintenanceProcesses()).isFalse();
|
|
assertThat(config.getEnableScheduledProcesses()).isFalse();
|
|
|
|
config.withEnableBasepullProcess(true);
|
|
assertThat(config.getEnableScheduledProcesses()).isFalse();
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testToString_redactsSecrets()
|
|
{
|
|
String s = baseConfig().withOpensearchUsername("admin").withOpensearchPassword("hunter2").toString();
|
|
assertThat(s).doesNotContain("hunter2").doesNotContain("admin").contains("[redacted]");
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
void testDefaults()
|
|
{
|
|
QuickSearchQBitConfig config = new QuickSearchQBitConfig();
|
|
assertThat(config.getStartupMode()).isEqualTo(QuickSearchStartupMode.FAIL_FAST);
|
|
assertThat(config.getEnableBasepullProcess()).isTrue();
|
|
assertThat(config.getEnableMaintenanceProcesses()).isTrue();
|
|
assertThat(config.getConnectTimeoutMillis()).isEqualTo(5000);
|
|
assertThat(config.getResponseTimeoutMillis()).isEqualTo(60000);
|
|
assertThat(config.getMaxSearchLimit()).isEqualTo(100);
|
|
assertThat(config.getBasepullOverlapSeconds()).isEqualTo(300);
|
|
assertThat(config.getFullReindexStaleMinutes()).isEqualTo(30);
|
|
assertThat(config.getAdminPermissionRules().getPermissionBaseName()).isEqualTo(QuickSearchQBitConfig.DEFAULT_ADMIN_PERMISSION_BASE_NAME);
|
|
assertThat(config.getApplyRecordSecurityLocks()).isTrue();
|
|
assertThat(config.getDefaultBackendNameForTables()).isNull();
|
|
assertThat(config.withBackendName("memory").getDefaultBackendNameForTables()).isEqualTo("memory");
|
|
}
|
|
|
|
}
|