Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
35a3605
feat: Allow custom options in the RunConfiguration
cristianonicolai Aug 13, 2026
2e651b5
feat: store execution profile in metadata
diogodanielsoaresferreira Sep 1, 2026
2b6b5b6
Merge branch 'main' into execution_profiles
diogodanielsoaresferreira Sep 2, 2026
8e22ec4
feat: remove execution profiles from metadata
diogodanielsoaresferreira Sep 4, 2026
f6e0240
Merge branch 'execution_profiles' of github.com:diogodanielsoaresferr…
diogodanielsoaresferreira Sep 4, 2026
b16cfe5
feat: rename execution profile artifacts to execution artifacts
diogodanielsoaresferreira Sep 4, 2026
dcb620a
Merge branch 'main' into execution_profiles
diogodanielsoaresferreira Sep 4, 2026
9c1479c
feat: add execution profiles to solver
diogodanielsoaresferreira Sep 4, 2026
ce245cd
feat: Remove default execution profile
diogodanielsoaresferreira Sep 7, 2026
a91a294
feat: move ExecutionProfileRegistry to internal package
diogodanielsoaresferreira Sep 7, 2026
29902da
Merge branch 'main' into execution_profiles
diogodanielsoaresferreira Sep 7, 2026
a1a595d
feat: remove applicationScoped from execution profile registry and us…
diogodanielsoaresferreira Sep 8, 2026
9b1de8d
Merge branch 'main' into execution_profiles
diogodanielsoaresferreira Sep 8, 2026
09c3c32
feat: Add seed execution profile
diogodanielsoaresferreira Sep 9, 2026
93fa617
feat: add name to execution profiles
diogodanielsoaresferreira Sep 11, 2026
b78efb4
feat: add name to execution profiles
diogodanielsoaresferreira Sep 11, 2026
cc08d65
Merge branch 'execution_profiles' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 11, 2026
609e890
Merge #2590 (custom options in RunConfiguration) into create_profile_…
diogodanielsoaresferreira Sep 14, 2026
da9707f
feat: execution profile parameters given by the caller
diogodanielsoaresferreira Sep 14, 2026
e6d05ae
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 15, 2026
57fe853
fix: sonarlog issues
diogodanielsoaresferreira Sep 15, 2026
90bb6b2
remove toProperties from execution profiles
diogodanielsoaresferreira Sep 15, 2026
52390fa
Fail if seed parameter is not supplied
diogodanielsoaresferreira Sep 15, 2026
5c806b4
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 17, 2026
17eea68
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 23, 2026
1be9439
feat: Update docs
diogodanielsoaresferreira Sep 23, 2026
6d0c846
documentation: add options in documentation
diogodanielsoaresferreira Sep 23, 2026
9fcfeaf
Merge branch 'main' into create_profile_set_custom_seed
diogodanielsoaresferreira Sep 28, 2026
604f7cd
feat: Add execution profile to generate thread dumps on OOMs
diogodanielsoaresferreira Sep 29, 2026
c9198d8
feat: remove memoryLimitMB from execution profile argument
diogodanielsoaresferreira Sep 30, 2026
925e57c
feat: add maxHeapMB as argument to limit heap size
diogodanielsoaresferreira Sep 30, 2026
1b2f4a0
feat: Add execution profile to generate thread dumps on OOMs
diogodanielsoaresferreira Sep 29, 2026
66977ce
feat: remove memoryLimitMB from execution profile argument
diogodanielsoaresferreira Sep 30, 2026
1d6259a
feat: add maxHeapMB as argument to limit heap size
diogodanielsoaresferreira Sep 30, 2026
0830ffc
Merge branch 'add_thread_dump_execution_profile' of github.com:diogod…
diogodanielsoaresferreira Oct 1, 2026
da86bbb
fix: revert docs changes
diogodanielsoaresferreira Oct 1, 2026
d8a80c8
feat: Realy on memory pressure to write heap and thread dumps
diogodanielsoaresferreira Oct 1, 2026
2b7de51
feat: rename to memory dump execution profile
diogodanielsoaresferreira Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package ai.timefold.solver.service.definition.impl.executionprofile;

import java.util.Map;

import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile;

/**
* Writes a thread dump and a heap dump when the solver runs short of memory, before the container is {@code OOMKilled}.
* <p>
* The profile enables the solver worker's memory watchdog, which checks the process memory against the container limit every
* second. The first time it reaches the threshold, the watchdog writes both dumps into the execution-profile artifacts
* directory and fails the run, which uploads them. A memory spike that kills the container between two checks is not
* captured.
* <p>
* The profile deliberately leaves the heap size alone, since lowering it could cause out-of-memory errors that would not
* otherwise occur. Instead, the watchdog fails the run early, leaving room below the container limit to write the dumps.
*/
public final class MemoryDumpExecutionProfile implements ExecutionProfile {

/** Environment-variable form of {@code ai.timefold.solver.monitoring.memory.enabled}. */
static final String ENV_MEMORY_WATCHDOG_ENABLED = "AI_TIMEFOLD_SOLVER_MONITORING_MEMORY_ENABLED";

@Override
public String id() {
return "memory-dump";
}

@Override
public String name() {
return "Thread and heap dump on high memory";
}

@Override
public String description() {
return "Writes a thread dump and a heap dump to the run's execution artifacts and fails the run when memory usage "
+ "gets close to the limit, before the container is killed.";
}

@Override
public Map<String, String> toEnvironment(Map<String, String> options) {
return Map.of(ENV_MEMORY_WATCHDOG_ENABLED, "true");
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
ai.timefold.solver.service.definition.impl.executionprofile.SeedExecutionProfile
ai.timefold.solver.service.definition.impl.executionprofile.MemoryDumpExecutionProfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package ai.timefold.solver.service.definition.impl.executionprofile;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Map;

import org.junit.jupiter.api.Test;

class MemoryDumpExecutionProfileTest {

private final MemoryDumpExecutionProfile profile = new MemoryDumpExecutionProfile();

@Test
void enablesMemoryWatchdog() {
assertThat(profile.toEnvironment(Map.of()))
.containsExactly(Map.entry("AI_TIMEFOLD_SOLVER_MONITORING_MEMORY_ENABLED", "true"));
}

@Test
void ignoresUnrelatedOptions() {
assertThat(profile.toEnvironment(Map.of("seed", "1")))
.isEqualTo(profile.toEnvironment(Map.of()));
}

@Test
void acceptsNullOptions() {
assertThat(profile.toEnvironment(null)).isEqualTo(profile.toEnvironment(Map.of()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.OptionalLong;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
Expand All @@ -21,9 +25,21 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.sun.management.HotSpotDiagnosticMXBean;
import com.sun.management.HotSpotDiagnosticMXBean.ThreadDumpFormat;

import io.quarkus.arc.Unremovable;
import io.quarkus.runtime.StartupEvent;

/**
* Fails the run when the JVM spends too much time in garbage collection.
* <p>
* When the memory watchdog is enabled ({@code ai.timefold.solver.monitoring.memory.enabled}, set by an execution profile), it
* also checks the process memory against the container memory limit at a short interval. The first time it reaches the
* threshold, it writes a thread dump and a heap dump into the execution-profile artifacts directory and fails the run, which
* uploads the artifacts. This happens before the container would be {@code OOMKilled}, so there is still room to write the
* dumps.
*/
@ApplicationScoped
@Unremovable
public class GcOverheadMonitoring {
Expand All @@ -35,6 +51,10 @@
private final SolverWorker solverWorker;
private final List<GarbageCollectorMXBean> gcBeans;
private final int gcThresholdPct;
private final boolean memoryWatchdogEnabled;
private final int memoryThresholdPct;
private final int memoryIntervalMillis;
private final AtomicBoolean memoryWatchdogTriggered = new AtomicBoolean(false);

private long prevGcTime = 0;

Expand All @@ -48,12 +68,21 @@
public GcOverheadMonitoring(SolverWorker solverWorker,
@ConfigProperty(name = "ai.timefold.solver.monitoring.gc.thresholdPct",
defaultValue = "100") Integer gcThresholdPct,
@ConfigProperty(name = "ai.timefold.solver.monitoring.gc.delaySeconds", defaultValue = "10") Integer delaySeconds) {
@ConfigProperty(name = "ai.timefold.solver.monitoring.gc.delaySeconds", defaultValue = "10") Integer delaySeconds,
@ConfigProperty(name = "ai.timefold.solver.monitoring.memory.enabled",
defaultValue = "false") Boolean memoryWatchdogEnabled,
@ConfigProperty(name = "ai.timefold.solver.monitoring.memory.thresholdPct",
defaultValue = "90") Integer memoryThresholdPct,
@ConfigProperty(name = "ai.timefold.solver.monitoring.memory.intervalMillis",
defaultValue = "1000") Integer memoryIntervalMillis) {
this.solverWorker = solverWorker;
this.gcBeans = ManagementFactory.getGarbageCollectorMXBeans();
this.prevGcTime = getTotalGcTime();
this.gcThresholdPct = gcThresholdPct;
this.delaySeconds = delaySeconds;
this.memoryWatchdogEnabled = memoryWatchdogEnabled;
this.memoryThresholdPct = memoryThresholdPct;
this.memoryIntervalMillis = memoryIntervalMillis;
}

public void onStart(@Observes StartupEvent event) {
Expand Down Expand Up @@ -83,6 +112,71 @@

prevGcTime = currGcTime;
}, delaySeconds, delaySeconds, TimeUnit.SECONDS);

if (memoryWatchdogEnabled) {
LOGGER.info("Memory watchdog enabled: dumping and failing the run at {}% memory usage", memoryThresholdPct);
executor.scheduleAtFixedRate(() -> {
try {
checkMemory(id);
} catch (Throwable e) {

Check warning on line 121 in service/worker/src/main/java/ai/timefold/solver/service/worker/impl/monitoring/GcOverheadMonitoring.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Catch Exception instead of Throwable.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaD4Hjh0mtLFNMjdgB4Q&open=AaD4Hjh0mtLFNMjdgB4Q&pullRequest=2710
// An exception would cancel the scheduled task, so keep the watchdog alive.
LOGGER.warn("Memory watchdog check failed due to {}", e.getMessage());
}
}, memoryIntervalMillis, memoryIntervalMillis, TimeUnit.MILLISECONDS);
}
}
}

private void checkMemory(String id) {
OptionalLong rss = MemoryPressure.readRssBytes();
OptionalLong containerLimit = MemoryPressure.containerMemoryLimitBytes();

String reason = MemoryPressure.exceededReason(rss, containerLimit, memoryThresholdPct);
if (reason == null || !memoryWatchdogTriggered.compareAndSet(false, true)) {
return;
}
LOGGER.error("Memory usage exceeded {}% ({}) - writing thread and heap dumps and shutting down",
memoryThresholdPct, reason);
Path dumpDir = createDumpDirectory();
if (dumpDir != null) {
writeDumps(dumpDir);
}
solverWorker.notifyOnFailure(id, new TimefoldRuntimeException(ErrorCodes.SOLVER_UNKNOWN,
"Not enough memory available to solve dataset - configure memory via configuration profile",
new OutOfMemoryError("Memory usage exceeded " + memoryThresholdPct + "% (" + reason + ")"),
false));
System.exit(JVM_MONITORING_EXIST_STATUS_CODE);
}

private static Path createDumpDirectory() {
String dir = System.getenv(EnvironmentVars.ENV_TIMEFOLD_EXECUTION_PROFILE_DIR);
Path dumpDir = Path.of(dir != null && !dir.isBlank() ? dir : EnvironmentVars.DEFAULT_EXECUTION_PROFILE_DIR)

Check warning on line 153 in service/worker/src/main/java/ai/timefold/solver/service/worker/impl/monitoring/GcOverheadMonitoring.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Declare this local variable with "var" instead.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaD4Hjh0mtLFNMjdgB4R&open=AaD4Hjh0mtLFNMjdgB4R&pullRequest=2710
.toAbsolutePath();
try {
return Files.createDirectories(dumpDir);
} catch (Exception e) {
LOGGER.warn("Unable to create dump directory {} due to {}", dumpDir, e.getMessage());
return null;
}
}

private static void writeDumps(Path dumpDir) {
long timestamp = System.currentTimeMillis();
HotSpotDiagnosticMXBean diagnostics = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
// Thread dump first: it is cheap and captures the threads before the heap dump pauses the JVM.
Path threadDump = dumpDir.resolve("thread-dump-" + timestamp + ".txt");
try {
diagnostics.dumpThreads(threadDump.toString(), ThreadDumpFormat.TEXT_PLAIN);
LOGGER.info("Thread dump written to {}", threadDump);
} catch (Exception e) {
LOGGER.warn("Unable to write thread dump to {} due to {}", threadDump, e.getMessage());
}
Path heapDump = dumpDir.resolve("heap-dump-" + timestamp + ".hprof");
try {
diagnostics.dumpHeap(heapDump.toString(), true);
LOGGER.info("Heap dump written to {}", heapDump);
} catch (Exception e) {
LOGGER.warn("Unable to write heap dump to {} due to {}", heapDump, e.getMessage());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package ai.timefold.solver.service.worker.impl.monitoring;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.OptionalLong;

import ai.timefold.solver.service.definition.internal.platform.EnvironmentVars;

/**
* Reads the process memory the memory watchdog in {@link GcOverheadMonitoring} compares against the container memory limit.
* <p>
* The process resident set size (RSS) is the memory the process actually uses, both the heap and everything outside it, so it
* is the closest single figure to what gets a container {@code OOMKilled}. It also counts heap that only holds garbage not yet
* collected, so the threshold should leave some margin for that.
*/
final class MemoryPressure {

static final Path PROC_SELF_STATUS = Path.of("/proc/self/status");
static final Path CGROUP_V2_MEMORY_MAX = Path.of("/sys/fs/cgroup/memory.max");
static final Path CGROUP_V1_MEMORY_LIMIT = Path.of("/sys/fs/cgroup/memory/memory.limit_in_bytes");

/** cgroup v1 reports an unlimited container as a value close to {@link Long#MAX_VALUE}. */
private static final long UNLIMITED_THRESHOLD = 1L << 60;

private MemoryPressure() {
}

static OptionalLong readRssBytes() {
try {
return parseRssBytes(Files.readAllLines(PROC_SELF_STATUS));
} catch (IOException | RuntimeException e) {
// Not on Linux or not readable; the RSS signal is simply unavailable.
return OptionalLong.empty();
}
}

static OptionalLong parseRssBytes(List<String> statusLines) {
for (String line : statusLines) {
if (line.startsWith("VmRSS:")) {
String[] parts = line.substring("VmRSS:".length()).trim().split("\\s+");
try {
return OptionalLong.of(Long.parseLong(parts[0]) * 1024);
} catch (NumberFormatException e) {
return OptionalLong.empty();
}
}
}
return OptionalLong.empty();
}

static OptionalLong containerMemoryLimitBytes() {
OptionalLong fromEnv = parseLimitBytes(System.getenv(EnvironmentVars.K8S_INFO_MEMORY_LIMIT));
if (fromEnv.isPresent()) {
return fromEnv;
}
for (Path path : List.of(CGROUP_V2_MEMORY_MAX, CGROUP_V1_MEMORY_LIMIT)) {
try {
OptionalLong limit = parseLimitBytes(Files.readString(path));
if (limit.isPresent()) {
return limit;
}
} catch (IOException | RuntimeException e) {
// Try the next location.
}
}
return OptionalLong.empty();
}

static OptionalLong parseLimitBytes(String value) {
if (value == null || value.isBlank()) {
return OptionalLong.empty();
}
try {
long limit = Long.parseLong(value.trim());

Check warning on line 76 in service/worker/src/main/java/ai/timefold/solver/service/worker/impl/monitoring/MemoryPressure.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Declare this local variable with "var" instead.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaD4HjmBmtLFNMjdgB4S&open=AaD4HjmBmtLFNMjdgB4S&pullRequest=2710
return limit > 0 && limit < UNLIMITED_THRESHOLD ? OptionalLong.of(limit) : OptionalLong.empty();
} catch (NumberFormatException e) {
// cgroup v2 reports an unlimited container as "max".
return OptionalLong.empty();
}
}

/**
* @return a description of the memory usage if it reached the threshold, or null otherwise
*/
static String exceededReason(OptionalLong rss, OptionalLong containerLimit, int thresholdPct) {

Check warning on line 87 in service/worker/src/main/java/ai/timefold/solver/service/worker/impl/monitoring/MemoryPressure.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Specify a "long" parameter instead.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaD4HjmBmtLFNMjdgB4T&open=AaD4HjmBmtLFNMjdgB4T&pullRequest=2710

Check warning on line 87 in service/worker/src/main/java/ai/timefold/solver/service/worker/impl/monitoring/MemoryPressure.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Specify a "long" parameter instead.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaD4HjmBmtLFNMjdgB4U&open=AaD4HjmBmtLFNMjdgB4U&pullRequest=2710
if (rss.isPresent() && containerLimit.isPresent()
&& rss.getAsLong() * 100 >= containerLimit.getAsLong() * thresholdPct) {
return "process memory " + toMiB(rss.getAsLong()) + " MiB of container limit "
+ toMiB(containerLimit.getAsLong()) + " MiB";
}
return null;
}

private static long toMiB(long bytes) {
return bytes / (1024 * 1024);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package ai.timefold.solver.service.worker.impl.monitoring;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;
import java.util.OptionalLong;

import org.junit.jupiter.api.Test;

class MemoryPressureTest {

private static final long MIB = 1024 * 1024;

@Test
void parsesRssFromProcStatus() {
List<String> status = List.of("Name:\tjava", "VmPeak:\t 9000 kB", "VmRSS:\t 2048 kB", "Threads:\t42");
assertThat(MemoryPressure.parseRssBytes(status)).hasValue(2048 * 1024);
}

@Test
void rssUnavailableWithoutVmRssLine() {
assertThat(MemoryPressure.parseRssBytes(List.of("Name:\tjava"))).isEmpty();
}

@Test
void parsesLimit() {
assertThat(MemoryPressure.parseLimitBytes("1073741824\n")).hasValue(1073741824);
}

@Test
void treatsUnlimitedAsNoLimit() {
assertThat(MemoryPressure.parseLimitBytes("max\n")).isEmpty(); // cgroup v2
assertThat(MemoryPressure.parseLimitBytes("9223372036854771712")).isEmpty(); // cgroup v1
assertThat(MemoryPressure.parseLimitBytes(null)).isEmpty();
assertThat(MemoryPressure.parseLimitBytes("")).isEmpty();
}

@Test
void belowThreshold() {
assertThat(MemoryPressure.exceededReason(OptionalLong.of(899 * MIB), OptionalLong.of(1000 * MIB), 90)).isNull();
}

@Test
void processMemoryReachesThreshold() {
assertThat(MemoryPressure.exceededReason(OptionalLong.of(900 * MIB), OptionalLong.of(1000 * MIB), 90))
.isEqualTo("process memory 900 MiB of container limit 1000 MiB");
}

@Test
void ignoredWithoutContainerLimit() {
assertThat(MemoryPressure.exceededReason(OptionalLong.of(5000 * MIB), OptionalLong.empty(), 90)).isNull();
}

@Test
void ignoredWithoutRss() {
assertThat(MemoryPressure.exceededReason(OptionalLong.empty(), OptionalLong.of(1000 * MIB), 90)).isNull();
}
}
Loading