Skip to content

Commit

Permalink
fix: Suppress Quarkus warnings generated for changing solver runtime …
Browse files Browse the repository at this point in the history
…properties

In order to know the names of Solver beans to generate, the
SolverBuildTimeConfig must extend the SolverRuntimeConfig, since all
build time properties of SolverBuildTimeConfig are optional
(so a named Solver might only be defined by termination, which is
a runtime property).

Quarkus does not provide a mechanism for defining additional
produces/instances of a bean at runtime (it goes against doing
all possible work at build time), and thus, all names of Solver
beans MUST be known at build time.

You cannot read a RuntimeConfig object inside a processor;
all its methods will be null. Thus, it is impossible to
get a list of runtime properties that were defined at
build time.

Having SolverBuildTimeConfig extend SolverRuntimeConfig causes
an issue. Namely, Quarkus, will generate a warning if a
SolverRuntimeConfig property changes at runtime, since, by
definition, it is also a SolverBuildTimeConfig property.

Quarkus provides a handy build item for surpressing the warning
(although its intended use is to hide sensitive data):
SuppressNonRuntimeConfigChangedWarningBuildItem.
Unfortunately, we need the EXACT name of the property to hide
(there was no programmatic method available for getting the name
from a method or class). Thus, we use ConfigurationBuildItem
to get all build time properties (which natuarally includes
the SolverBuildTimeConfig properties), filter them to check
only properties that start with TimefoldBuildTimeConfig prefix,
and suppress warnings for any property that match the filter and
ends with a runtime property suffix.
  • Loading branch information
Christopher-Chianelli committed Jan 7, 2025
1 parent 6271534 commit 10450fb
Show file tree
Hide file tree
Showing 4 changed files with 131 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import jakarta.enterprise.context.ApplicationScoped;
Expand Down Expand Up @@ -43,6 +44,7 @@
import ai.timefold.solver.quarkus.bean.DefaultTimefoldBeanProvider;
import ai.timefold.solver.quarkus.bean.TimefoldSolverBannerBean;
import ai.timefold.solver.quarkus.bean.UnavailableTimefoldBeanProvider;
import ai.timefold.solver.quarkus.config.SolverRuntimeConfig;
import ai.timefold.solver.quarkus.config.TimefoldRuntimeConfig;
import ai.timefold.solver.quarkus.deployment.config.SolverBuildTimeConfig;
import ai.timefold.solver.quarkus.deployment.config.TimefoldBuildTimeConfig;
Expand Down Expand Up @@ -76,10 +78,12 @@
import io.quarkus.deployment.annotations.Record;
import io.quarkus.deployment.builditem.BytecodeTransformerBuildItem;
import io.quarkus.deployment.builditem.CombinedIndexBuildItem;
import io.quarkus.deployment.builditem.ConfigurationBuildItem;
import io.quarkus.deployment.builditem.FeatureBuildItem;
import io.quarkus.deployment.builditem.GeneratedClassBuildItem;
import io.quarkus.deployment.builditem.HotDeploymentWatchedFileBuildItem;
import io.quarkus.deployment.builditem.IndexDependencyBuildItem;
import io.quarkus.deployment.builditem.SuppressNonRuntimeConfigChangedWarningBuildItem;
import io.quarkus.deployment.builditem.nativeimage.ReflectiveHierarchyBuildItem;
import io.quarkus.deployment.pkg.steps.NativeBuild;
import io.quarkus.deployment.recording.RecorderContext;
Expand All @@ -89,11 +93,14 @@
import io.quarkus.gizmo.ClassOutput;
import io.quarkus.gizmo.MethodDescriptor;
import io.quarkus.gizmo.ResultHandle;
import io.quarkus.runtime.annotations.ConfigGroup;
import io.quarkus.runtime.annotations.ConfigRoot;
import io.quarkus.runtime.configuration.ConfigurationException;

class TimefoldProcessor {

private static final Logger log = Logger.getLogger(TimefoldProcessor.class.getName());
private static final Pattern CAPITAL_LETTER_PATTERN = Pattern.compile("[A-Z]");

TimefoldBuildTimeConfig timefoldBuildTimeConfig;

Expand Down Expand Up @@ -173,8 +180,28 @@ void makeSolverFactoryUnremovableInDevMode(BuildProducer<UnremovableBeanBuildIte
unremovableBeans.produce(UnremovableBeanBuildItem.beanTypes(SolverFactory.class));
}

/**
* Converts a method's camelCase name to the kabab-case name used in properties.
*/
private String toKebabCase(String camelCaseName) {
var matcher = CAPITAL_LETTER_PATTERN.matcher(camelCaseName);
return matcher.replaceAll(letter -> "-" + letter.group().toLowerCase());
}

private void addAllConfigProperties(Class<?> config, String prefix, Set<String> result) {
for (var method : config.getMethods()) {
if (method.getReturnType().getAnnotation(ConfigGroup.class) != null) {
addAllConfigProperties(method.getReturnType(), prefix + "." + toKebabCase(method.getName()), result);
} else {
result.add(prefix + "." + toKebabCase(method.getName()));
}
}
}

@BuildStep
SolverConfigBuildItem recordAndRegisterBuildTimeBeans(CombinedIndexBuildItem combinedIndex,
ConfigurationBuildItem configurationBuildItem,
BuildProducer<SuppressNonRuntimeConfigChangedWarningBuildItem> suppressRuntimeConfigChange,
BuildProducer<ReflectiveHierarchyBuildItem> reflectiveHierarchyClass,
BuildProducer<SyntheticBeanBuildItem> syntheticBeanBuildItemBuildProducer,
BuildProducer<AdditionalBeanBuildItem> additionalBeans,
Expand All @@ -201,17 +228,38 @@ SolverConfigBuildItem recordAndRegisterBuildTimeBeans(CombinedIndexBuildItem com

// Quarkus extensions must always use getContextClassLoader()
// Internally, Timefold defaults the ClassLoader to getContextClassLoader() too
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
var classLoader = Thread.currentThread().getContextClassLoader();

var solverConfigMap = new HashMap<String, SolverConfig>();
var solverNames = new HashSet<>(timefoldBuildTimeConfig.solver().keySet());

Map<String, SolverConfig> solverConfigMap = new HashMap<>();
// Step 0 - Suppress all changed at runtime warnings for runtime properties that were included in the build time
// configuration so we can determine the list of solver names
var timefoldSolverPropertyPrefix = TimefoldBuildTimeConfig.class.getAnnotation(ConfigRoot.class).prefix();
var runtimePropertySuffices = new HashSet<String>();
addAllConfigProperties(SolverRuntimeConfig.class, "", runtimePropertySuffices);

for (var property : configurationBuildItem.getReadResult().getAllBuildTimeValues().keySet()) {
if (!property.startsWith(timefoldSolverPropertyPrefix)) {
// Not a timefold solver property; skip
continue;
}
for (var suffix : runtimePropertySuffices) {
if (property.endsWith(suffix)) {
// This is a solver runtime property, so suppress the changed at runtime warning generated for it.
suppressRuntimeConfigChange.produce(new SuppressNonRuntimeConfigChangedWarningBuildItem(property));
break;
}
}
}
// Step 1 - create all SolverConfig
// If the config map is empty, we build the config using the default solver name
if (timefoldBuildTimeConfig.solver().isEmpty()) {
if (solverNames.isEmpty()) {
solverConfigMap.put(TimefoldBuildTimeConfig.DEFAULT_SOLVER_NAME,
createSolverConfig(classLoader, TimefoldBuildTimeConfig.DEFAULT_SOLVER_NAME));
} else {
// One config per solver mapped name
this.timefoldBuildTimeConfig.solver().keySet().forEach(solverName -> solverConfigMap.put(solverName,
solverNames.forEach(solverName -> solverConfigMap.put(solverName,
createSolverConfig(classLoader, solverName)));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
import ai.timefold.solver.core.api.score.stream.ConstraintStreamImplType;
import ai.timefold.solver.core.config.solver.EnvironmentMode;
import ai.timefold.solver.core.config.solver.SolverConfig;
import ai.timefold.solver.core.config.solver.termination.TerminationConfig;
import ai.timefold.solver.quarkus.config.SolverRuntimeConfig;
import ai.timefold.solver.quarkus.config.TerminationRuntimeConfig;

import io.quarkus.runtime.annotations.ConfigGroup;

Expand All @@ -19,7 +17,7 @@
* @see SolverRuntimeConfig
*/
@ConfigGroup
public interface SolverBuildTimeConfig {
public interface SolverBuildTimeConfig extends SolverRuntimeConfig {

/**
* A classpath resource to read the specific solver configuration XML.
Expand Down Expand Up @@ -47,22 +45,6 @@ public interface SolverBuildTimeConfig {
*/
Optional<DomainAccessType> domainAccessType();

/**
* Note: this setting is only available
* for <a href="https://timefold.ai/docs/timefold-solver/latest/enterprise-edition/enterprise-edition">Timefold Solver
* Enterprise Edition</a>.
* Enable multithreaded solving for a single problem, which increases CPU consumption.
* Defaults to {@value SolverConfig#MOVE_THREAD_COUNT_NONE}.
* Other options include {@value SolverConfig#MOVE_THREAD_COUNT_AUTO}, a number
* or formula based on the available processor count.
*/
Optional<String> moveThreadCount();

/**
* Configuration properties regarding {@link TerminationConfig}.
*/
TerminationRuntimeConfig termination();

/**
* Enable the Nearby Selection quick configuration.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package ai.timefold.solver.quarkus;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.logging.Level;

import ai.timefold.solver.quarkus.testdata.normal.constraints.TestdataQuarkusConstraintProvider;
import ai.timefold.solver.quarkus.testdata.normal.domain.TestdataQuarkusEntity;
import ai.timefold.solver.quarkus.testdata.normal.domain.TestdataQuarkusSolution;

import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;

class TimefoldProcessorWarningBuildTimePropertyChangedTest {
@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.overrideConfigKey("quarkus.timefold.solver.daemon", "true")
// We overwrite the value at runtime
.overrideRuntimeConfigKey("quarkus.timefold.solver.daemon", "false")
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
.addClasses(TestdataQuarkusEntity.class, TestdataQuarkusSolution.class,
TestdataQuarkusConstraintProvider.class))
// Make sure Quarkus does not produce a warning for overwriting a build time value at runtime
.setLogRecordPredicate(record -> record.getLoggerName().startsWith("io.quarkus")
&& record.getLevel().intValue() >= Level.WARNING.intValue())
.assertLogRecords(logRecords -> {
assertEquals(1, logRecords.size(), "expected warning to be generated");
});

@Test
void solverProperties() {
// Test is done by assertLogRecords
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package ai.timefold.solver.quarkus;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.logging.Level;

import ai.timefold.solver.quarkus.testdata.normal.constraints.TestdataQuarkusConstraintProvider;
import ai.timefold.solver.quarkus.testdata.normal.domain.TestdataQuarkusEntity;
import ai.timefold.solver.quarkus.testdata.normal.domain.TestdataQuarkusSolution;

import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.spec.JavaArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;

class TimefoldProcessorWarningRuntimePropertyChangedTest {
@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
.overrideConfigKey("quarkus.timefold.solver.move-thread-count", "1")
.overrideConfigKey("quarkus.timefold.solver.termination.spent-limit", "1s")
// We overwrite the value at runtime
.overrideRuntimeConfigKey("quarkus.timefold.solver.move-thread-count", "2")
.overrideRuntimeConfigKey("quarkus.timefold.solver.termination.spent-limit", "2s")
.setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class)
.addClasses(TestdataQuarkusEntity.class, TestdataQuarkusSolution.class,
TestdataQuarkusConstraintProvider.class))
// Make sure Quarkus does not produce a warning for overwriting a build time value at runtime
.setLogRecordPredicate(record -> record.getLoggerName().startsWith("io.quarkus")
&& record.getLevel().intValue() >= Level.WARNING.intValue())
.assertLogRecords(logRecords -> {
assertEquals(0, logRecords.size(), "expected no warnings to be generated");
});

@Test
void solverProperties() {
// Test is done by assertLogRecords
}
}

0 comments on commit 10450fb

Please sign in to comment.