This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 2a2f6cf9e UNOMI-957: Add cross-platform memory sampling for
integration test runs (#782)
2a2f6cf9e is described below
commit 2a2f6cf9e5dfa3be064b67b5d22c4d139c883c37
Author: Serge Huber <[email protected]>
AuthorDate: Sat Jun 27 21:56:15 2026 +0200
UNOMI-957: Add cross-platform memory sampling for integration test runs
(#782)
Merge memory sampling improvement
---
.github/workflows/unomi-ci-build-tests.yml | 10 +
build.sh | 43 ++
itests/README.md | 56 +-
itests/archive-it-run.sh | 36 +-
itests/lib/it-run-context.sh | 6 +
itests/lib/it-run-memory.sh | 651 +++++++++++++++++++++
itests/lib/it-run.sh | 200 ++++++-
itests/sample-it-memory.sh | 229 ++++++++
.../impl/cluster/ClusterServiceImplTest.java | 23 +-
.../impl/scheduler/SchedulerServiceImplTest.java | 16 +-
10 files changed, 1249 insertions(+), 21 deletions(-)
diff --git a/.github/workflows/unomi-ci-build-tests.yml
b/.github/workflows/unomi-ci-build-tests.yml
index af90f2747..2b7e190dd 100644
--- a/.github/workflows/unomi-ci-build-tests.yml
+++ b/.github/workflows/unomi-ci-build-tests.yml
@@ -70,6 +70,16 @@ jobs:
else
./build.sh --ci --integration-tests
fi
+ - name: Archive IT memory metrics
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: it-memory-metrics-jdk17-${{ matrix.search-engine }}-${{
github.run_number }}
+ path: |
+ itests/target/memory-samples.tsv
+ itests/target/memory-summary.txt
+ itests/target/memory-sampler.log
+ if-no-files-found: ignore
- name: Archive code coverage logs
uses: actions/upload-artifact@v4
if: false # UNOMI-746 Reactivate if necessary
diff --git a/build.sh b/build.sh
index a88ce8a0f..c6eaa7837 100755
--- a/build.sh
+++ b/build.sh
@@ -272,6 +272,8 @@ IT_DEBUG_PORT=5006
IT_DEBUG_SUSPEND=false
SKIP_MIGRATION_TESTS=false
KEEP_CONTAINER=false
+IT_MEMORY_SAMPLER=true
+IT_MEMORY_INTERVAL=30
JAVADOC=false
LOG_FILE=""
LOG_FILE_ONLY=false
@@ -315,6 +317,8 @@ EOF
echo -e " ${CYAN}--it-debug-suspend${NC} Suspend integration
test until debugger connects"
echo -e " ${CYAN}--skip-migration-tests${NC} Skip
migration-related tests"
echo -e " ${CYAN}--keep-container${NC} Keep search engine
container running after tests (for post-failure inspection)"
+ echo -e " ${CYAN}--no-memory-sampler${NC} Disable JVM/system
memory sampling during integration tests"
+ echo -e " ${CYAN}--memory-interval SEC${NC} Memory sample interval
in seconds (default: 30)"
echo -e " ${CYAN}--javadoc${NC} Build and validate
Javadoc after install (fails on doclint errors)"
echo -e " ${CYAN}--ci${NC} CI mode: no Karaf,
no Maven build cache, non-interactive, includes Javadoc"
echo -e " ${CYAN}--log-file PATH${NC} Tee all output to
PATH (console + file)"
@@ -354,6 +358,8 @@ EOF
echo " --it-debug-suspend Suspend integration test until
debugger connects"
echo " --skip-migration-tests Skip migration-related tests"
echo " --keep-container Keep search engine container running
after tests (for post-failure inspection)"
+ echo " --no-memory-sampler Disable JVM/system memory sampling
during integration tests"
+ echo " --memory-interval SEC Memory sample interval in seconds
(default: 30)"
echo " --javadoc Build and validate Javadoc after
install (fails on doclint errors)"
echo " --ci CI mode: no Karaf, no Maven build
cache, non-interactive, includes Javadoc"
echo " --log-file PATH Tee all output to PATH (console +
file)"
@@ -505,6 +511,13 @@ while [ "$1" != "" ]; do
--keep-container)
KEEP_CONTAINER=true
;;
+ --no-memory-sampler)
+ IT_MEMORY_SAMPLER=false
+ ;;
+ --memory-interval)
+ shift
+ IT_MEMORY_INTERVAL="$1"
+ ;;
--javadoc)
JAVADOC=true
;;
@@ -1037,6 +1050,8 @@ write_it_run_trace_start() {
echo "it.debug.suspend=$IT_DEBUG_SUSPEND"
echo "skip.migration.tests=$SKIP_MIGRATION_TESTS"
echo "it.keep.container=$KEEP_CONTAINER"
+ echo "it.memory.sampler=$IT_MEMORY_SAMPLER"
+ echo "it.memory.interval=$IT_MEMORY_INTERVAL"
echo "maven.debug=$MAVEN_DEBUG"
echo "maven.offline=$MAVEN_OFFLINE"
echo "maven.quiet=$MAVEN_QUIET"
@@ -1048,6 +1063,31 @@ write_it_run_trace_start() {
} > "$trace_file"
}
+
+start_it_memory_sampler() {
+ local sampler="$DIRNAME/itests/sample-it-memory.sh"
+ if [ "$IT_MEMORY_SAMPLER" != true ] || [ ! -f "$sampler" ]; then
+ return 0
+ fi
+ bash "$sampler" start --target-dir "$DIRNAME/itests/target" --interval
"$IT_MEMORY_INTERVAL" || \
+ print_status "warning" "Could not start IT memory sampler"
+}
+
+write_it_run_operator_note() {
+ local sampler="$DIRNAME/itests/sample-it-memory.sh"
+ if [ -f "$sampler" ]; then
+ bash "$sampler" operator-note --target-dir "$DIRNAME/itests/target"
2>/dev/null || true
+ fi
+}
+
+stop_it_memory_sampler() {
+ local sampler="$DIRNAME/itests/sample-it-memory.sh"
+ if [ ! -f "$sampler" ]; then
+ return 0
+ fi
+ bash "$sampler" stop --target-dir "$DIRNAME/itests/target" 2>/dev/null ||
true
+}
+
finalize_it_run_trace() {
local exit_code="$1"
local trace_file="$DIRNAME/itests/target/it-run-trace.properties"
@@ -1074,6 +1114,7 @@ $MVN_CMD clean $MVN_OPTS || {
if [ "$RUN_INTEGRATION_TESTS" = true ]; then
write_it_run_trace_start
+ start_it_memory_sampler
fi
print_progress $((++current_step)) $total_steps "Compiling and installing
artifacts..."
@@ -1085,7 +1126,9 @@ fi
INSTALL_EXIT=0
$MVN_CMD install $MVN_OPTS || INSTALL_EXIT=$?
if [ "$RUN_INTEGRATION_TESTS" = true ]; then
+ stop_it_memory_sampler
finalize_it_run_trace "$INSTALL_EXIT"
+ write_it_run_operator_note
fi
if [ "$INSTALL_EXIT" -ne 0 ]; then
print_status "error" "Maven install failed"
diff --git a/itests/README.md b/itests/README.md
index b2500f8fb..e3929d611 100644
--- a/itests/README.md
+++ b/itests/README.md
@@ -45,8 +45,60 @@ run under `itests/target/exam/` with a UUID directory name.
| `kt.sh` | Live Karaf inspection during a run (log, tail, grep, debug) |
| `archive-it-run.sh` | Capture IT run artifacts before the next build wipes
them |
| `compare-it-runs.sh` | Diff multiple captures to classify flaky vs
systematic failures |
+| `sample-it-memory.sh` | Memory sampling, summarize, operator note, and
cross-platform verify |
| `jacoco-report.sh` | Generate a JaCoCo coverage report after a run |
+
+---
+
+## Memory sizing analysis
+
+`./build.sh --integration-tests` starts a background memory sampler
automatically.
+Results land in `itests/target/`:
+
+| File | Purpose |
+|------|---------|
+| `memory-samples.tsv` | Time-series samples (Karaf heap, search heap, Docker
RSS, swap) |
+| `memory-summary.txt` | Peak usage, headroom %, peak Karaf GCT (seconds),
swap warnings |
+| `memory-sampler.log` | Sampler diagnostics |
+
+Disable sampling with `--no-memory-sampler`, or change the interval with
+`--memory-interval 60` (default: 30s).
+
+Each sample uses one header-aware `jstat -gc` attach (cached `MaxHeapSize` per
Karaf PID,
+with `jinfo`/`jcmd`/trace fallbacks), a filtered ES `/_nodes/stats/jvm`
request, and
+`docker stats` on the IT container only (`itests-elasticsearch` or
`itests-opensearch`).
+System metrics use `vm_stat`/`sysctl` on macOS and `free`/`/proc/loadavg` on
Linux.
+
+Swap warnings fire only when available RAM drops below 2 GB during the run or
swap
+grows by more than 256 MB (avoids false positives from baseline macOS swap).
+
+Verify locally:
+
+```bash
+cd itests
+./sample-it-memory.sh verify
+```
+
+Manual usage:
+
+```bash
+./itests/sample-it-memory.sh start --interval 30
+./itests/sample-it-memory.sh stop # writes memory-summary.txt
+./itests/sample-it-memory.sh summarize
+./itests/sample-it-memory.sh operator-note --print-only
+```
+
+After a run, `build.sh` writes `itests/target/it-run-operator-note.txt` with
outcome,
+heap config, test counts, and memory peaks. `archive-it-run.sh` uses that file
as the
+default operator note when you omit `-m` (override with `-m` or
`--message-file` as before).
+
+The archive also includes memory files and adds peak metrics to
`run-summary.properties`.
+On GitHub Actions, download the `it-memory-metrics-*` artifact from the
workflow run.
+
+Compare heap configurations across runs using `archives/runs-index.tsv`
(configured
+heaps + observed peaks from `memory.peak.*` fields in each capture).
+
---
## Writing Integration Tests
@@ -325,8 +377,8 @@ wipes `itests/target/`:
```bash
cd itests
-./archive-it-run.sh
-./archive-it-run.sh -m "Heavy swap, 2 failures in GraphQLListIT" # with an
operator note
+./archive-it-run.sh # uses auto-generated
operator note
+./archive-it-run.sh -m "Heavy swap, 2 failures in GraphQLListIT" # override
auto note
./archive-it-run.sh --full-karaf # include
complete Karaf log
```
diff --git a/itests/archive-it-run.sh b/itests/archive-it-run.sh
index cb9fafa60..afb161ef9 100755
--- a/itests/archive-it-run.sh
+++ b/itests/archive-it-run.sh
@@ -48,6 +48,8 @@
# - llm-it-run-analysis-guide.md, expected-karaf-log-patterns.txt
# - exam/.../karaf-unexpected-candidates.log (errors not matching expected
patterns)
# - test-results.tsv, run-summary.properties, failed-tests.txt (LLM-friendly
per-run test manifest)
+# - it-run-operator-note.txt (auto-generated operator note from build.sh)
+# - memory-samples.tsv, memory-summary.txt (JVM/system memory observed
during IT run)
# - run-context.txt, run-config/it-run-trace.properties (build/Maven/options
trace)
# - archives/runs-index.tsv (updated each capture — cross-run comparison
index)
# - comparison-last-3.txt, archives/latest-comparison.txt (auto when 2+
captures exist)
@@ -64,6 +66,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
source "$SCRIPT_DIR/lib/it-run-bootstrap.sh"
# shellcheck source=lib/it-run-karaf.sh disable=SC1091
source "$SCRIPT_DIR/lib/it-run-karaf.sh"
+# shellcheck source=lib/it-run-memory.sh disable=SC1091
+source "$SCRIPT_DIR/lib/it-run-memory.sh"
# shellcheck source=lib/it-run-context.sh disable=SC1091
source "$SCRIPT_DIR/lib/it-run-context.sh"
@@ -85,6 +89,7 @@ RUN_CONFIG_FILES=(
elasticsearch-port.properties
opensearch-port.properties
it-run-trace.properties
+ it-run-operator-note.txt
)
ENGINE_LOG_TREES=(
@@ -113,7 +118,7 @@ usage() {
echo " (default) Unexploded directory:
archives/it-run-YYYYMMDD-HHMMSS/"
echo " --tar Write a .tar.gz instead (default name under
archives/)"
echo " -o PATH Output directory, or .tar.gz / .tgz archive path"
- echo " -m, --message Operator note about run context (quoted string)"
+ echo " -m, --message Operator note (default: it-run-operator-note.txt
from build.sh)"
echo " --message-file Read operator note from a file"
echo " --full-karaf Include complete karaf.log and rollover segments
(default: tail + filtered errors)"
echo " --no-compare Skip auto compare of last 3 captures (default: on
when 2+ runs exist)"
@@ -121,6 +126,17 @@ usage() {
exit 1
}
+
+load_default_operator_note() {
+ if [ -n "$RUN_MESSAGE" ]; then
+ return 0
+ fi
+ if RUN_MESSAGE="$(it_load_default_operator_note "$TARGET_DIR"
"$SCRIPT_DIR" 2>/dev/null)"; then
+ ui_detail "Using auto-generated operator note from
$IT_OPERATOR_NOTE_FILE"
+ return 0
+ fi
+}
+
parse_args() {
while [ $# -gt 0 ]; do
case "$1" in
@@ -373,6 +389,10 @@ write_run_summary() {
echo "tests.skipped=$(it_failsafe_summary_count "$summary_xml"
skipped)"
fi
echo "failed.tests.count=$failed_count"
+ if [ -f "$(target_path "$IT_MEMORY_SUMMARY")" ]; then
+ grep -E '^memory\.(peak|min|warning|samples|karaf|search)\.' \
+ "$(target_path "$IT_MEMORY_SUMMARY")" 2>/dev/null || true
+ fi
if [ -n "$RUN_FINGERPRINT" ]; then
echo "${IT_RUN_FINGERPRINT_FIELD}=$RUN_FINGERPRINT"
fi
@@ -416,11 +436,24 @@ archive_engine_log_trees() {
done
}
+archive_memory_artifacts() {
+ local name samples summary
+ for name in "$IT_MEMORY_SAMPLES" "$IT_MEMORY_SUMMARY"
"$IT_MEMORY_SAMPLER_LOG"; do
+ copy_file "$(target_path "$name")" "$name"
+ done
+ samples="$(target_path "$IT_MEMORY_SAMPLES")"
+ summary="$(target_path "$IT_MEMORY_SUMMARY")"
+ if [ -f "$samples" ] && [ ! -f "$summary" ]; then
+ it_memory_summarize_samples "$samples" "$summary" && copy_file
"$summary" "$IT_MEMORY_SUMMARY"
+ fi
+}
+
archive_test_artifacts() {
archive_report_trees
capture_test_manifest
archive_run_config_files
archive_engine_log_trees
+ archive_memory_artifacts
write_run_context
}
@@ -493,6 +526,7 @@ finalize_output() {
main() {
it_run_entry_init "$SCRIPT_DIR"
parse_args "$@"
+ load_default_operator_note
require_target_dir
reject_duplicate_archive
diff --git a/itests/lib/it-run-context.sh b/itests/lib/it-run-context.sh
index f09f73782..c7bbe52d5 100644
--- a/itests/lib/it-run-context.sh
+++ b/itests/lib/it-run-context.sh
@@ -20,6 +20,7 @@
# Expects archive hooks: target_path, staging_path, log_staged_file,
mark_included,
# and globals: SCRIPT_DIR, REPO_ROOT, TARGET_DIR, MANIFEST, OUTPUT,
RUN_MESSAGE,
# RUN_ID, FULL_KARAF, CREATE_TAR, RUN_CONTEXT_REL, RUN_TRACE_FILE.
+# Requires it-run-memory.sh to be sourced by the caller (archive-it-run.sh).
context_append_inferred_maven_properties() {
local engine
@@ -72,6 +73,7 @@ context_append_system_snapshot() {
if [ "$(uname -s 2>/dev/null)" = Darwin ] && command -v memory_pressure
>/dev/null 2>&1; then
echo "snapshot.memory_pressure=$(memory_pressure 2>/dev/null | head
-1)"
fi
+ it_memory_append_system_snapshot
}
context_emit_manifest_git_lines() {
@@ -151,6 +153,10 @@ write_run_context() {
echo
echo "## System snapshot (at archive time — not at IT start)"
context_append_system_snapshot
+ echo
+ it_memory_append_context_section \
+ "$(target_path "$IT_MEMORY_SUMMARY")" \
+ "$(target_path "$IT_MEMORY_SAMPLES")"
} > "$out"
log_staged_file "$RUN_CONTEXT_REL" "Wrote $RUN_CONTEXT_REL"
mark_included
diff --git a/itests/lib/it-run-memory.sh b/itests/lib/it-run-memory.sh
new file mode 100644
index 000000000..1c8f43466
--- /dev/null
+++ b/itests/lib/it-run-memory.sh
@@ -0,0 +1,651 @@
+################################################################################
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+################################################################################
+# JVM and system memory sampling helpers for integration test runs.
+# Requires lib/it-run.sh to be sourced first (shared engine + POM helpers).
+
+IT_MEMORY_SAMPLES="memory-samples.tsv"
+IT_MEMORY_SUMMARY="memory-summary.txt"
+IT_MEMORY_SAMPLER_PID="memory-sampler.pid"
+IT_MEMORY_SAMPLER_STOP="memory-sampler.stop"
+IT_MEMORY_SAMPLER_LOG="memory-sampler.log"
+IT_MEMORY_SAMPLER_CACHE="memory-sampler.cache"
+IT_MEMORY_SWAP_PRESSURE_MB=2048
+
+IT_MEMORY_TSV_HEADER=$'timestamp_utc\tkaraf_pid\tkaraf_heap_used_mb\tkaraf_heap_max_mb\tkaraf_gct_s\tes_heap_used_mb\tes_heap_max_mb\tdocker_rss_mb\tsystem_mem_available_mb\tsystem_swap_used_mb\tsystem_load_1m'
+
+_IT_MEMORY_OS=""
+
+it_memory_os_name() {
+ if [ -z "$_IT_MEMORY_OS" ]; then
+ _IT_MEMORY_OS="$(uname -s 2>/dev/null || echo unknown)"
+ fi
+ echo "$_IT_MEMORY_OS"
+}
+
+it_memory_is_linux() {
+ [ "$(it_memory_os_name)" = Linux ]
+}
+
+it_memory_is_darwin() {
+ [ "$(it_memory_os_name)" = Darwin ]
+}
+
+it_memory_parse_heap_setting_mb() {
+ local value="${1:-}"
+ local number suffix
+
+ value="$(echo "$value" | tr -d '[:space:]')"
+ if [ -z "$value" ]; then
+ echo "0"
+ return
+ fi
+
+ suffix="${value: -1}"
+ number="${value%?}"
+ case "$suffix" in
+ g | G) awk -v n="$number" 'BEGIN { printf "%d", int(n * 1024 + 0.5) }'
;;
+ m | M) awk -v n="$number" 'BEGIN { printf "%d", int(n + 0.5) }' ;;
+ [0-9]) awk -v n="$value" 'BEGIN { printf "%d", int(n / 1048576 + 0.5)
}' ;;
+ *) echo "0" ;;
+ esac
+}
+
+it_memory_parse_jvm_flag_bytes() {
+ local output="$1"
+ local flag="$2"
+
+ echo "$output" | sed -n
"s/.*${flag}[[:space:]]*=[[:space:]]*\([0-9][0-9]*\).*/\1/p" | head -1
+}
+
+it_memory_read_trace_heap_mb() {
+ local target_dir="$1"
+ local trace_field="$2"
+ local pom_profile="$3"
+ local pom_field="$4"
+ local script_dir trace_file configured
+
+ script_dir="$(cd "$(dirname "$target_dir")" && pwd)"
+ trace_file="$target_dir/it-run-trace.properties"
+ if [ ! -f "$script_dir/pom.xml" ]; then
+ echo "0"
+ return
+ fi
+ configured="$(it_resolve_configured_heap "$trace_file" "$trace_field"
"$script_dir/pom.xml" "$pom_profile" "$pom_field")"
+ it_memory_parse_heap_setting_mb "$configured"
+}
+
+it_memory_detect_karaf_max_mb() {
+ local target_dir="$1"
+ local pid="$2"
+ local cap_mb_from_jstat="${3:-0}"
+ local max_bytes max_mb jinfo_out jcmd_out profile
+
+ max_mb="0"
+
+ if command -v jinfo >/dev/null 2>&1; then
+ jinfo_out="$(jinfo -flag MaxHeapSize "$pid" 2>/dev/null || true)"
+ max_bytes="$(it_memory_parse_jvm_flag_bytes "$jinfo_out"
"MaxHeapSize")"
+ if [ -n "$max_bytes" ]; then
+ max_mb="$(it_memory_mb_from_bytes "$max_bytes")"
+ fi
+ fi
+
+ if [ "$max_mb" = "0" ] && command -v jcmd >/dev/null 2>&1; then
+ jcmd_out="$(jcmd "$pid" VM.flags 2>/dev/null || true)"
+ max_bytes="$(it_memory_parse_jvm_flag_bytes "$jcmd_out" "MaxHeapSize")"
+ if [ -n "$max_bytes" ]; then
+ max_mb="$(it_memory_mb_from_bytes "$max_bytes")"
+ fi
+ fi
+
+ if [ "$max_mb" = "0" ] && [ "$cap_mb_from_jstat" -gt 0 ] 2>/dev/null; then
+ max_mb="$cap_mb_from_jstat"
+ fi
+
+ if [ "$max_mb" = "0" ]; then
+ profile="$(it_search_engine_pom_profile
"$(it_memory_resolve_search_engine "$target_dir")")"
+ max_mb="$(it_memory_read_trace_heap_mb "$target_dir" "karaf.heap"
"$profile" "karaf.heap")"
+ fi
+
+ echo "$max_mb"
+}
+
+it_memory_jstat_gc_metrics() {
+ local pid="$1"
+
+ if ! command -v jstat >/dev/null 2>&1; then
+ echo -e "0\t0\t0"
+ return
+ fi
+
+ jstat -gc "$pid" 2>/dev/null | awk '
+ NR == 1 {
+ for (i = 1; i <= NF; i++) {
+ h[$i] = i
+ }
+ next
+ }
+ NR == 2 {
+ if ("S0U" in h) {
+ used_kb = $(h["S0U"]) + $(h["S1U"]) + $(h["EU"]) + $(h["OU"])
+ } else {
+ used_kb = $3 + $4 + $6 + $8
+ }
+ if ("S0C" in h) {
+ cap_kb = $(h["S0C"]) + $(h["S1C"]) + $(h["EC"]) + $(h["OC"])
+ } else {
+ cap_kb = $1 + $2 + $5 + $7
+ }
+ if ("GCT" in h) {
+ gct = $(h["GCT"]) + 0
+ } else {
+ gct = $NF + 0
+ }
+ printf "%d\t%d\t%.3f", int(used_kb * 1024 / 1048576 + 0.5),
int(cap_kb * 1024 / 1048576 + 0.5), gct
+ }'
+}
+
+it_memory_system_mem_available_mb() {
+ local pagesize
+
+ if it_memory_is_linux && command -v free >/dev/null 2>&1; then
+ free -m 2>/dev/null | awk '/^Mem:/ {
+ if ($7 != "") {
+ print $7
+ } else {
+ print $4
+ }
+ exit
+ }'
+ return
+ fi
+
+ if it_memory_is_darwin && command -v vm_stat >/dev/null 2>&1; then
+ pagesize="$(sysctl -n hw.pagesize 2>/dev/null || echo 4096)"
+ vm_stat 2>/dev/null | awk -v ps="$pagesize" '
+ /Pages free/ { gsub(/\./, "", $3); free = $3 + 0 }
+ /Pages inactive/ { gsub(/\./, "", $3); inactive = $3 + 0 }
+ END {
+ printf "%d", int((free + inactive) * ps / 1048576 + 0.5)
+ }'
+ return
+ fi
+
+ echo "0"
+}
+
+it_memory_system_swap_used_mb() {
+ if it_memory_is_linux && command -v free >/dev/null 2>&1; then
+ free -m 2>/dev/null | awk '/^Swap:/ { print $3; exit }'
+ return
+ fi
+
+ if it_memory_is_darwin; then
+ sysctl -n vm.swapusage 2>/dev/null | sed -n 's/.* used =
\([0-9.]*\)M.*/\1/p' | awk '{ printf "%d", int($1 + 0.5) }'
+ return
+ fi
+
+ echo "0"
+}
+
+it_memory_system_load_1m() {
+ if it_memory_is_linux && [ -r /proc/loadavg ]; then
+ awk '{ print $1 + 0 }' /proc/loadavg
+ return
+ fi
+
+ if it_memory_is_darwin && command -v sysctl >/dev/null 2>&1; then
+ sysctl -n vm.loadavg 2>/dev/null | tr -d '{}\n' | awk '{ print $1 + 0
}'
+ return
+ fi
+
+ if command -v uptime >/dev/null 2>&1; then
+ uptime 2>/dev/null | sed -n 's/.*load average[s]*: //p' | cut -d, -f1
| tr -d ' '
+ return
+ fi
+
+ echo "0"
+}
+
+it_memory_sampler_cache_file() {
+ echo "$1/$IT_MEMORY_SAMPLER_CACHE"
+}
+
+it_memory_read_cache_field() {
+ local cache_file="$1"
+ local field="$2"
+ if [ ! -f "$cache_file" ]; then
+ return 1
+ fi
+ grep -m1 "^${field}=" "$cache_file" 2>/dev/null | cut -d= -f2-
+}
+
+it_memory_write_cache_field() {
+ local cache_file="$1"
+ local field="$2"
+ local value="$3"
+ local tmp
+
+ mkdir -p "$(dirname "$cache_file")"
+ touch "$cache_file"
+ tmp="$(mktemp "${TMPDIR:-/tmp}/unomi-mem-cache.XXXXXX")"
+ grep -v "^${field}=" "$cache_file" > "$tmp" 2>/dev/null || true
+ printf '%s=%s\n' "$field" "$value" >> "$tmp"
+ mv "$tmp" "$cache_file"
+}
+
+it_memory_clear_sampler_cache() {
+ rm -f "$(it_memory_sampler_cache_file "$1")"
+}
+
+it_memory_resolve_search_engine() {
+ local target_dir="$1"
+ local engine
+
+ if [ -n "${IT_SEARCH_ENGINE:-}" ]; then
+ echo "$IT_SEARCH_ENGINE"
+ return
+ fi
+ engine="$(it_infer_search_engine "$target_dir")"
+ if [ -n "$engine" ] && [ "$engine" != "unknown" ]; then
+ echo "$engine"
+ return
+ fi
+ echo "elasticsearch"
+}
+
+it_memory_resolve_docker_container() {
+ local target_dir="$1"
+ local cache_file engine container default_container
+
+ cache_file="$(it_memory_sampler_cache_file "$target_dir")"
+ container="$(it_memory_read_cache_field "$cache_file" docker_container ||
true)"
+ if [ -n "$container" ]; then
+ echo "$container"
+ return
+ fi
+
+ engine="$(it_memory_resolve_search_engine "$target_dir")"
+ default_container="$(it_search_engine_docker_container "$engine")"
+
+ if command -v docker >/dev/null 2>&1; then
+ container="$(docker ps --filter "name=${default_container}" --format
'{{.Names}}' 2>/dev/null | head -1)"
+ fi
+
+ if [ -z "$container" ]; then
+ echo "$default_container"
+ return
+ fi
+
+ it_memory_write_cache_field "$cache_file" docker_container "$container"
+ echo "$container"
+}
+
+it_memory_mb_from_bytes() {
+ local bytes="$1"
+ if [ -z "$bytes" ] || [ "$bytes" = "0" ]; then
+ echo "0"
+ return
+ fi
+ echo $(( (bytes + 524288) / 1048576 ))
+}
+
+it_memory_json_number() {
+ local json="$1"
+ local field="$2"
+ echo "$json" | sed -n "s/.*\"${field}\":\([0-9][0-9]*\).*/\1/p" | head -1
+}
+
+it_memory_resolve_search_port() {
+ local target_dir="$1"
+ local engine port_file port_line
+
+ engine="$(it_memory_resolve_search_engine "$target_dir")"
+ port_file="$target_dir/$(it_search_engine_port_properties_file "$engine")"
+
+ if [ -f "$port_file" ]; then
+ port_line="$(grep -m1 '\.port=' "$port_file" 2>/dev/null | cut -d=
-f2- | tr -d '[:space:]')"
+ if [ -n "$port_line" ]; then
+ echo "$port_line"
+ return
+ fi
+ fi
+
+ if [ -n "${IT_SEARCH_PORT:-}" ]; then
+ echo "$IT_SEARCH_PORT"
+ return
+ fi
+
+ it_search_engine_default_port "$engine"
+}
+
+it_memory_parse_docker_mem_to_mb() {
+ local raw="${1:-}"
+
+ case "$raw" in
+ *GiB) echo "${raw%GiB}" | awk '{printf "%d", $1 * 1024 + 0.5}' ;;
+ *MiB) echo "${raw%MiB}" | awk '{printf "%d", $1 + 0.5}' ;;
+ *KiB) echo "${raw%KiB}" | awk '{printf "%d", int($1 / 1024 + 0.5)}' ;;
+ *G) echo "${raw%G}" | awk '{printf "%d", $1 * 1024 + 0.5}' ;;
+ *M) echo "${raw%M}" | awk '{printf "%d", $1 + 0.5}' ;;
+ *K) echo "${raw%K}" | awk '{printf "%d", int($1 / 1024 + 0.5)}' ;;
+ *) echo "0" ;;
+ esac
+}
+
+it_memory_find_karaf_pid() {
+ pgrep -f 'org.apache.karaf.main.Main' 2>/dev/null | head -1
+}
+
+it_memory_karaf_max_mb_cached() {
+ local target_dir="$1"
+ local pid="$2"
+ local cap_mb_from_jstat="${3:-0}"
+ local cache_file cached_pid max_mb
+
+ cache_file="$(it_memory_sampler_cache_file "$target_dir")"
+ cached_pid="$(it_memory_read_cache_field "$cache_file" karaf_pid || true)"
+ if [ "$cached_pid" = "$pid" ]; then
+ max_mb="$(it_memory_read_cache_field "$cache_file" karaf_max_mb ||
true)"
+ if [ -n "$max_mb" ] && [ "$max_mb" -gt 0 ] 2>/dev/null; then
+ echo "$max_mb"
+ return
+ fi
+ fi
+
+ max_mb="$(it_memory_detect_karaf_max_mb "$target_dir" "$pid"
"$cap_mb_from_jstat")"
+ if [ -z "$max_mb" ]; then
+ max_mb="0"
+ fi
+
+ it_memory_write_cache_field "$cache_file" karaf_pid "$pid"
+ if [ "$max_mb" -gt 0 ] 2>/dev/null; then
+ it_memory_write_cache_field "$cache_file" karaf_max_mb "$max_mb"
+ else
+ it_memory_write_cache_field "$cache_file" karaf_max_mb ""
+ fi
+ echo "$max_mb"
+}
+
+it_memory_karaf_stats() {
+ local target_dir="$1"
+ local pid="$2"
+ local used_mb max_mb cap_mb gct_s jstat_line
+
+ if [ -z "$pid" ]; then
+ echo -e "0\t0\t0"
+ return
+ fi
+
+ if ! kill -0 "$pid" 2>/dev/null; then
+ echo -e "0\t0\t0"
+ return
+ fi
+
+ used_mb="0"
+ cap_mb="0"
+ gct_s="0"
+ jstat_line="$(it_memory_jstat_gc_metrics "$pid")"
+ if [ -n "$jstat_line" ]; then
+ read -r used_mb cap_mb gct_s <<EOF
+$jstat_line
+EOF
+ fi
+
+ max_mb="$(it_memory_karaf_max_mb_cached "$target_dir" "$pid" "$cap_mb")"
+
+ if [ -z "${used_mb:-}" ] || [ "$used_mb" = "0" ]; then
+ if command -v ps >/dev/null 2>&1; then
+ used_mb="$(ps -o rss= -p "$pid" 2>/dev/null | awk '{print
int($1/1024+0.5)}')"
+ fi
+ fi
+
+ if [ -z "${max_mb:-}" ] || [ "$max_mb" = "0" ]; then
+ max_mb="0"
+ fi
+ if [ -z "${used_mb:-}" ]; then
+ used_mb="0"
+ fi
+ if [ -z "${gct_s:-}" ]; then
+ gct_s="0"
+ fi
+
+ echo -e "${used_mb}\t${max_mb}\t${gct_s}"
+}
+
+it_memory_search_engine_stats() {
+ local port="$1"
+ local json used_bytes max_bytes
+
+ json="$(curl -sf --max-time 5 \
+
"http://localhost:${port}/_nodes/stats/jvm?filter_path=nodes.*.jvm.mem.heap_used_in_bytes,nodes.*.jvm.mem.heap_max_in_bytes"
\
+ 2>/dev/null || true)"
+ if [ -z "$json" ]; then
+ echo -e "0\t0"
+ return
+ fi
+
+ used_bytes="$(it_memory_json_number "$json" "heap_used_in_bytes")"
+ max_bytes="$(it_memory_json_number "$json" "heap_max_in_bytes")"
+ echo -e "$(it_memory_mb_from_bytes
"${used_bytes:-0}")\t$(it_memory_mb_from_bytes "${max_bytes:-0}")"
+}
+
+it_memory_docker_rss_mb() {
+ local target_dir="$1"
+ local container rss
+
+ if ! command -v docker >/dev/null 2>&1; then
+ echo "0"
+ return
+ fi
+
+ container="$(it_memory_resolve_docker_container "$target_dir")"
+ rss="$(docker stats --no-stream --format '{{.MemUsage}}' "$container"
2>/dev/null | head -1 | cut -d/ -f1 | tr -d ' ')"
+
+ it_memory_parse_docker_mem_to_mb "$rss"
+}
+
+it_memory_system_stats() {
+ local mem_available swap_used load_1m
+
+ mem_available="$(it_memory_system_mem_available_mb)"
+ swap_used="$(it_memory_system_swap_used_mb)"
+ load_1m="$(it_memory_system_load_1m)"
+
+ echo -e "${mem_available:-0}\t${swap_used:-0}\t${load_1m:-0}"
+}
+
+it_memory_sample_once() {
+ local target_dir="$1"
+ local port="${2:-}"
+ local karaf_pid karaf_line es_line sys_line
+
+ port="${port:-$(it_memory_resolve_search_port "$target_dir")}"
+ karaf_pid="$(it_memory_find_karaf_pid)"
+ karaf_line="$(it_memory_karaf_stats "$target_dir" "$karaf_pid")"
+ es_line="$(it_memory_search_engine_stats "$port")"
+ sys_line="$(it_memory_system_stats)"
+
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ "${karaf_pid:-0}" \
+ "$karaf_line" \
+ "$es_line" \
+ "$(it_memory_docker_rss_mb "$target_dir")" \
+ "$sys_line"
+}
+
+it_memory_write_samples_header() {
+ local samples_file="$1"
+ if [ ! -f "$samples_file" ] || [ ! -s "$samples_file" ]; then
+ printf '%s\n' "$IT_MEMORY_TSV_HEADER" > "$samples_file"
+ fi
+}
+
+it_memory_summarize_samples() {
+ local samples_file="$1"
+ local summary_file="$2"
+
+ if [ ! -f "$samples_file" ] || [ ! -s "$samples_file" ]; then
+ return 1
+ fi
+
+ awk -F'\t' -v summary="$summary_file" -v
swap_pressure_mb="$IT_MEMORY_SWAP_PRESSURE_MB" '
+ NR == 1 { next }
+ NF < 11 { next }
+ {
+ samples++
+ if ($2+0 > 0) {
+ if ($3+0 > peak_karaf_used) peak_karaf_used = $3+0
+ if ($4+0 > peak_karaf_max) peak_karaf_max = $4+0
+ if ($5+0 > peak_karaf_gct_s) peak_karaf_gct_s = $5+0
+ }
+ if ($6+0 > peak_es_used) peak_es_used = $6+0
+ if ($7+0 > peak_es_max) peak_es_max = $7+0
+ if ($8+0 > peak_docker_rss) peak_docker_rss = $8+0
+ if ($9+0 > 0 && (min_mem_avail == 0 || $9+0 < min_mem_avail))
min_mem_avail = $9+0
+ if ($10+0 > peak_swap) peak_swap = $10+0
+ if ($11+0 > peak_load) peak_load = $11+0
+ if (samples == 1) first_swap = $10+0
+ last_swap = $10+0
+ }
+ END {
+ if (samples == 0) exit 1
+ karaf_headroom = (peak_karaf_max > 0 ? (peak_karaf_max -
peak_karaf_used) * 100 / peak_karaf_max : 0)
+ es_headroom = (peak_es_max > 0 ? (peak_es_max - peak_es_used) *
100 / peak_es_max : 0)
+ swap_pressure = (peak_swap+0 > 0 && min_mem_avail+0 > 0 &&
min_mem_avail+0 < swap_pressure_mb+0) || (last_swap+0 > first_swap+0 + 256)
+ printf("# Integration test memory summary (generated from %s)\n",
FILENAME) > summary
+ printf("memory.samples.count=%d\n", samples) >> summary
+ printf("memory.peak.karaf.heap.used.mb=%d\n", peak_karaf_used+0)
>> summary
+ printf("memory.peak.karaf.heap.max.mb=%d\n", peak_karaf_max+0) >>
summary
+ printf("memory.peak.karaf.gct.s=%.2f\n", peak_karaf_gct_s+0) >>
summary
+ printf("memory.peak.search.heap.used.mb=%d\n", peak_es_used+0) >>
summary
+ printf("memory.peak.search.heap.max.mb=%d\n", peak_es_max+0) >>
summary
+ printf("memory.peak.docker.rss.mb=%d\n", peak_docker_rss+0) >>
summary
+ printf("memory.min.system.mem.available.mb=%d\n", min_mem_avail+0)
>> summary
+ printf("memory.peak.system.swap.used.mb=%d\n", peak_swap+0) >>
summary
+ printf("memory.peak.system.load.1m=%.2f\n", peak_load+0) >> summary
+ printf("memory.karaf.headroom.pct=%d\n", karaf_headroom+0) >>
summary
+ printf("memory.search.headroom.pct=%d\n", es_headroom+0) >> summary
+ if (swap_pressure) {
+ printf("memory.warning.swap.detected=true\n") >> summary
+ } else {
+ printf("memory.warning.swap.detected=false\n") >> summary
+ }
+ if (peak_karaf_max > 0 && peak_karaf_used * 100 / peak_karaf_max
>= 85) {
+ printf("memory.warning.karaf.heap.high=true\n") >> summary
+ } else {
+ printf("memory.warning.karaf.heap.high=false\n") >> summary
+ }
+ if (peak_es_max > 0 && peak_es_used * 100 / peak_es_max >= 85) {
+ printf("memory.warning.search.heap.high=true\n") >> summary
+ } else {
+ printf("memory.warning.search.heap.high=false\n") >> summary
+ }
+ }
+ ' "$samples_file"
+}
+
+it_memory_append_context_section() {
+ local summary_file="$1"
+ local samples_file="$2"
+
+ if [ -f "$summary_file" ]; then
+ echo "## Memory summary (observed during IT run)"
+ cat "$summary_file"
+ echo
+ elif [ -f "$samples_file" ]; then
+ echo "## Memory samples"
+ echo "# memory-summary.txt missing; raw samples available at
$(basename "$samples_file")"
+ echo "memory.samples.lines=$(($(wc -l < "$samples_file" 2>/dev/null |
tr -d ' ') - 1))"
+ echo
+ fi
+}
+
+it_memory_append_system_snapshot() {
+ if it_memory_is_linux && command -v free >/dev/null 2>&1; then
+ echo "snapshot.mem.linux=$(free -h 2>/dev/null | awk '/^Mem:/ {print
$0}')"
+ echo "snapshot.swap.linux=$(free -h 2>/dev/null | awk '/^Swap:/ {print
$0}')"
+ fi
+ echo "snapshot.mem.available.mb=$(it_memory_system_mem_available_mb)"
+ echo "snapshot.swap.used.mb=$(it_memory_system_swap_used_mb)"
+ echo "snapshot.load.1m=$(it_memory_system_load_1m)"
+}
+
+it_memory_verify() {
+ local target_dir="$1"
+ local script_dir="${2:-$(cd "$(dirname "$target_dir")" && pwd)}"
+ local pom="$script_dir/pom.xml"
+ local failures=0 karaf_pid sample_line
+
+ _it_memory_verify_eq() {
+ if [ "$2" = "$3" ]; then
+ echo "OK $1"
+ else
+ echo "FAIL $1 (expected '$2', got '$3')"
+ failures=$((failures + 1))
+ fi
+ }
+
+ _it_memory_verify_gt_zero() {
+ if awk -v v="${2:-0}" 'BEGIN { exit !(v + 0 > 0) }'; then
+ echo "OK $1 ($2)"
+ else
+ echo "FAIL $1 (expected > 0, got '${2:-0}')"
+ failures=$((failures + 1))
+ fi
+ }
+
+ echo "Memory sampling verification on $(it_memory_os_name)"
+ _it_memory_verify_eq "parse 2g" "2048" "$(it_memory_parse_heap_setting_mb
2g)"
+ _it_memory_verify_eq "parse 1536m" "1536"
"$(it_memory_parse_heap_setting_mb 1536m)"
+ _it_memory_verify_eq "parse docker GiB" "2048"
"$(it_memory_parse_docker_mem_to_mb 2GiB)"
+ _it_memory_verify_eq "parse docker MiB" "512"
"$(it_memory_parse_docker_mem_to_mb 512MiB)"
+ _it_memory_verify_eq "parse docker KiB" "1"
"$(it_memory_parse_docker_mem_to_mb 1024KiB)"
+ _it_memory_verify_eq "parse jinfo flag" "2147483648" \
+ "$(it_memory_parse_jvm_flag_bytes '-XX:MaxHeapSize=2147483648'
'MaxHeapSize')"
+ _it_memory_verify_eq "search engine default port" "9400"
"$(it_search_engine_default_port elasticsearch)"
+ _it_memory_verify_eq "search engine container" "itests-elasticsearch"
"$(it_search_engine_docker_container elasticsearch)"
+ _it_memory_verify_eq "pom elasticsearch.heap" "4g" \
+ "$(it_read_pom_profile_property "$pom" elasticsearch
elasticsearch.heap 2>/dev/null || true)"
+ _it_memory_verify_eq "pom karaf.heap" "2g" \
+ "$(it_read_pom_profile_property "$pom" elasticsearch karaf.heap
2>/dev/null || true)"
+ _it_memory_verify_gt_zero "system load 1m" "$(it_memory_system_load_1m)"
+ _it_memory_verify_gt_zero "system mem available mb"
"$(it_memory_system_mem_available_mb)"
+ echo "OK system swap used mb ($(it_memory_system_swap_used_mb))"
+
+ karaf_pid="$(it_memory_find_karaf_pid || true)"
+ if [ -n "$karaf_pid" ]; then
+ echo "Karaf PID $karaf_pid — live checks"
+ sample_line="$(it_memory_sample_once "$target_dir")"
+ _it_memory_verify_gt_zero "sample karaf_heap_max_mb" "$(echo
"$sample_line" | awk -F'\t' '{print $4}')"
+ _it_memory_verify_gt_zero "sample system_load_1m parseable" \
+ "$(echo "$sample_line" | awk -F'\t' '{print ($11+0 > 0)}')"
+ echo "Sample: $sample_line"
+ else
+ echo "SKIP live Karaf checks (no Karaf process)"
+ fi
+
+ if [ "$failures" -eq 0 ]; then
+ echo "All checks passed."
+ return 0
+ fi
+ echo "$failures check(s) failed."
+ return 1
+}
diff --git a/itests/lib/it-run.sh b/itests/lib/it-run.sh
index ed50a8136..dbe9b83c4 100644
--- a/itests/lib/it-run.sh
+++ b/itests/lib/it-run.sh
@@ -37,7 +37,9 @@ IT_ENGINE_PORT_FILES=(
elasticsearch-port.properties
opensearch-port.properties
)
-IT_RUN_CONTEXT_NO_OPERATOR_NOTE='(none — pass --message "..." to describe run
conditions, e.g. heavy swap, CI runner, single-test rerun)'
+IT_RUN_CONTEXT_NO_OPERATOR_NOTE='(none — auto-generated note missing; run via
./build.sh --integration-tests or pass -m "...")'
+IT_RUN_TRACE="it-run-trace.properties"
+IT_OPERATOR_NOTE_FILE="it-run-operator-note.txt"
it_run_lib_init() {
IT_SCRIPT_DIR="$1"
@@ -77,16 +79,35 @@ it_read_pom_profile_property() {
awk -v profile="$profile" -v prop="$property" '
$0 ~ "<id>" profile "</id>" { in_profile=1 }
- in_profile && $0 ~ "<" prop ">" {
- gsub(/.*<[^>]+>/, "")
- gsub(/<.*/, "")
- print
+ in_profile && index($0, "<" prop ">") {
+ start = index($0, "<" prop ">") + length(prop) + 2
+ rest = substr($0, start)
+ end = index(rest, "<")
+ if (end > 0) {
+ print substr(rest, 1, end - 1)
+ }
exit
}
in_profile && $0 ~ "</profile>" { exit }
' "$pom"
}
+it_resolve_configured_heap() {
+ local trace_file="$1"
+ local trace_field="$2"
+ local pom="$3"
+ local profile="$4"
+ local pom_field="$5"
+ local configured
+
+ configured="$(it_read_properties_field "$trace_file" "$trace_field")"
+ if [ -n "$configured" ]; then
+ echo "$configured"
+ return
+ fi
+ it_read_pom_profile_property "$pom" "$profile" "$pom_field"
+}
+
it_infer_search_engine() {
local target_dir="$1"
@@ -103,6 +124,41 @@ it_infer_search_engine() {
fi
}
+it_search_engine_pom_profile() {
+ case "$1" in
+ opensearch) echo "opensearch" ;;
+ *) echo "elasticsearch" ;;
+ esac
+}
+
+it_search_engine_heap_pom_field() {
+ case "$1" in
+ opensearch) echo "opensearch.heap" ;;
+ *) echo "elasticsearch.heap" ;;
+ esac
+}
+
+it_search_engine_docker_container() {
+ case "$1" in
+ opensearch) echo "itests-opensearch" ;;
+ *) echo "itests-elasticsearch" ;;
+ esac
+}
+
+it_search_engine_port_properties_file() {
+ case "$1" in
+ opensearch) echo "opensearch-port.properties" ;;
+ *) echo "elasticsearch-port.properties" ;;
+ esac
+}
+
+it_search_engine_default_port() {
+ case "$1" in
+ opensearch) echo "9401" ;;
+ *) echo "9400" ;;
+ esac
+}
+
it_run_label() {
basename "$1"
}
@@ -469,3 +525,137 @@ it_update_runs_index() {
} > "$index"
ui_detail "Updated $index"
}
+
+it_format_configured_heap() {
+ local trace_file="$1"
+ local trace_field="$2"
+ local pom="$3"
+ local profile="$4"
+ local pom_field="$5"
+ local trace_value resolved
+
+ trace_value="$(it_read_properties_field "$trace_file" "$trace_field")"
+ if [ -n "$trace_value" ]; then
+ echo "$trace_value"
+ return
+ fi
+ resolved="$(it_resolve_configured_heap "$trace_file" "$trace_field" "$pom"
"$profile" "$pom_field")"
+ if [ -n "$resolved" ]; then
+ echo "${resolved} (pom default)"
+ else
+ echo "unknown"
+ fi
+}
+
+it_generate_operator_note() {
+ local target_dir="$1"
+ local script_dir="$2"
+ local trace_file="$target_dir/$IT_RUN_TRACE"
+ local memory_summary="$target_dir/${IT_MEMORY_SUMMARY:-memory-summary.txt}"
+ local failsafe_summary="$target_dir/failsafe-reports/failsafe-summary.xml"
+ local engine profile search_heap karaf_heap exit_code single_test
+ local failures errors skipped completed started completed_ts host ci
+ local swap_warn search_high karaf_high
+
+ if [ ! -f "$trace_file" ]; then
+ return 1
+ fi
+
+ engine="$(it_read_properties_field "$trace_file" search.engine)"
+ if [ -z "$engine" ]; then
+ engine="$(it_infer_search_engine "$target_dir")"
+ fi
+ profile="$(it_search_engine_pom_profile "$engine")"
+ search_heap="$(it_format_configured_heap "$trace_file" search.heap
"$script_dir/pom.xml" "$profile" "$(it_search_engine_heap_pom_field
"$engine")")"
+ karaf_heap="$(it_format_configured_heap "$trace_file" karaf.heap
"$script_dir/pom.xml" "$profile" karaf.heap)"
+ exit_code="$(it_read_properties_field "$trace_file" maven.exit.code)"
+ single_test="$(it_read_properties_field "$trace_file" single.test)"
+ started="$(it_read_properties_field "$trace_file" trace.started)"
+ completed_ts="$(it_read_properties_field "$trace_file" trace.completed)"
+ host="$(it_read_properties_field "$trace_file" host)"
+ if [ -z "$host" ]; then
+ host="$(it_hostname)"
+ fi
+ if [ -n "${GITHUB_ACTIONS:-}" ]; then
+ ci="yes (${GITHUB_WORKFLOW:-workflow}/${GITHUB_JOB:-job})"
+ else
+ ci="no"
+ fi
+
+ failures="$(it_failsafe_summary_count "$failsafe_summary" failures)"
+ errors="$(it_failsafe_summary_count "$failsafe_summary" errors)"
+ skipped="$(it_failsafe_summary_count "$failsafe_summary" skipped)"
+ completed="$(it_failsafe_summary_count "$failsafe_summary" completed)"
+
+ swap_warn="$(it_read_properties_field "$memory_summary"
memory.warning.swap.detected)"
+ search_high="$(it_read_properties_field "$memory_summary"
memory.warning.search.heap.high)"
+ karaf_high="$(it_read_properties_field "$memory_summary"
memory.warning.karaf.heap.high)"
+
+ echo "Auto-generated operator note ($(it_utc_now))"
+ if [ "$exit_code" = "0" ]; then
+ echo "Outcome: PASS (maven exit 0)"
+ elif [ -n "$exit_code" ]; then
+ echo "Outcome: FAIL (maven exit ${exit_code})"
+ else
+ echo "Outcome: unknown (run still in progress or trace incomplete)"
+ fi
+ echo "Search engine: ${engine}"
+ echo "Heaps: search=${search_heap}, karaf=${karaf_heap}"
+ echo "Host: ${host}; CI: ${ci}"
+ if [ -n "$started" ] && [ -n "$completed_ts" ]; then
+ echo "Run window: ${started} → ${completed_ts}"
+ elif [ -n "$started" ]; then
+ echo "Run started: ${started}"
+ fi
+ if [ -n "$single_test" ]; then
+ echo "Scope: single test ${single_test}"
+ else
+ echo "Scope: full AllITs suite"
+ fi
+ if [ -n "$completed" ]; then
+ echo "Tests: completed=${completed:-0} failures=${failures:-0}
errors=${errors:-0} skipped=${skipped:-0}"
+ fi
+ if [ -f "$memory_summary" ]; then
+ echo "Memory peaks: karaf=$(it_read_properties_field "$memory_summary"
memory.peak.karaf.heap.used.mb)/$(it_read_properties_field "$memory_summary"
memory.peak.karaf.heap.max.mb) MB, karaf GCT=$(it_read_properties_field
"$memory_summary" memory.peak.karaf.gct.s)s, search=$(it_read_properties_field
"$memory_summary" memory.peak.search.heap.used.mb)/$(it_read_properties_field
"$memory_summary" memory.peak.search.heap.max.mb) MB,
swap=$(it_read_properties_field "$memory_summary" mem [...]
+ if [ "$swap_warn" = "true" ]; then
+ echo "Memory alert: swap detected during run — consider lowering
JVM heaps or using a larger runner"
+ fi
+ if [ "$search_high" = "true" ]; then
+ echo "Memory alert: search engine heap usage high (>=85% of max) —
may be GC-bound"
+ fi
+ if [ "$karaf_high" = "true" ]; then
+ echo "Memory alert: Karaf heap usage high (>=85% of max) — may be
GC-bound"
+ fi
+ else
+ echo "Memory peaks: (no memory-summary.txt — sampler disabled or run
archived before finalize)"
+ fi
+}
+
+it_write_operator_note_file() {
+ local target_dir="$1"
+ local script_dir="$2"
+ local out="$target_dir/$IT_OPERATOR_NOTE_FILE"
+
+ mkdir -p "$target_dir"
+ if ! it_generate_operator_note "$target_dir" "$script_dir" > "$out"; then
+ rm -f "$out"
+ return 1
+ fi
+}
+
+it_load_default_operator_note() {
+ local target_dir="$1"
+ local script_dir="$2"
+ local note_file="$target_dir/$IT_OPERATOR_NOTE_FILE"
+
+ if [ -f "$note_file" ]; then
+ cat "$note_file"
+ return 0
+ fi
+ if [ -f "$target_dir/$IT_RUN_TRACE" ]; then
+ it_write_operator_note_file "$target_dir" "$script_dir" || return 1
+ cat "$note_file"
+ return 0
+ fi
+ return 1
+}
diff --git a/itests/sample-it-memory.sh b/itests/sample-it-memory.sh
new file mode 100755
index 000000000..19e0d5d5c
--- /dev/null
+++ b/itests/sample-it-memory.sh
@@ -0,0 +1,229 @@
+#!/bin/bash
+################################################################################
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You 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.
+#
+################################################################################
+#
+# Sample JVM and system memory while integration tests run.
+#
+# Usage:
+# ./itests/sample-it-memory.sh start [--target-dir itests/target]
[--interval 30]
+# ./itests/sample-it-memory.sh stop [--target-dir itests/target]
+# ./itests/sample-it-memory.sh sample-once [--target-dir itests/target]
+# ./itests/sample-it-memory.sh summarize [--target-dir itests/target]
+# ./itests/sample-it-memory.sh operator-note [--target-dir itests/target]
[--print-only]
+# ./itests/sample-it-memory.sh verify [--target-dir itests/target]
+# Started automatically by ./build.sh --integration-tests (disable with
--no-memory-sampler).
+#
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=lib/it-run.sh disable=SC1091
+source "$SCRIPT_DIR/lib/it-run.sh"
+# shellcheck source=lib/it-run-memory.sh disable=SC1091
+source "$SCRIPT_DIR/lib/it-run-memory.sh"
+
+TARGET_DIR="$SCRIPT_DIR/target"
+INTERVAL=30
+SEARCH_PORT=""
+PRINT_ONLY=false
+COMMAND=""
+
+usage() {
+ cat <<EOF
+Usage: $0 <command> [options]
+
+Commands:
+ start Run the sampler in the background until "stop" is called
+ stop Stop the background sampler and write memory-summary.txt
+ sample-once Print one TSV sample line to stdout
+ summarize Build memory-summary.txt from memory-samples.tsv
+ operator-note Write it-run-operator-note.txt from trace + test + memory data
+ verify Cross-platform sanity checks (macOS + Linux)
+
+Options:
+ --target-dir DIR IT target directory (default: itests/target)
+ --interval SEC Sample interval in seconds for start (default: 30)
+ --port PORT Search engine HTTP port override
+ --print-only With operator-note: print to stdout instead of writing
file
+ -h, --help Show this help
+EOF
+ exit 1
+}
+
+parse_args() {
+ COMMAND="${1:-}"
+ shift || true
+
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --target-dir)
+ shift
+ TARGET_DIR="${1:-}"
+ ;;
+ --interval)
+ shift
+ INTERVAL="${1:-30}"
+ ;;
+ --port)
+ shift
+ SEARCH_PORT="${1:-}"
+ ;;
+ --print-only)
+ PRINT_ONLY=true
+ ;;
+ -h | --help)
+ usage
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ usage
+ ;;
+ esac
+ shift
+ done
+
+ if [ -z "$COMMAND" ]; then
+ usage
+ fi
+
+ mkdir -p "$TARGET_DIR"
+ if [ -n "$SEARCH_PORT" ]; then
+ IT_SEARCH_PORT="$SEARCH_PORT"
+ export IT_SEARCH_PORT
+ fi
+}
+
+sampler_paths() {
+ SAMPLES_FILE="$TARGET_DIR/$IT_MEMORY_SAMPLES"
+ SUMMARY_FILE="$TARGET_DIR/$IT_MEMORY_SUMMARY"
+ PID_FILE="$TARGET_DIR/$IT_MEMORY_SAMPLER_PID"
+ STOP_FILE="$TARGET_DIR/$IT_MEMORY_SAMPLER_STOP"
+ LOG_FILE="$TARGET_DIR/$IT_MEMORY_SAMPLER_LOG"
+}
+
+sampler_loop() {
+ local interval="$1"
+ sampler_paths
+ it_memory_write_samples_header "$SAMPLES_FILE"
+
+ while [ ! -f "$STOP_FILE" ]; do
+ it_memory_sample_once "$TARGET_DIR" >> "$SAMPLES_FILE" || true
+ sleep "$interval"
+ done
+}
+
+cmd_start() {
+ sampler_paths
+
+ if [ -f "$PID_FILE" ]; then
+ existing_pid="$(cat "$PID_FILE" 2>/dev/null || true)"
+ if [ -n "$existing_pid" ] && kill -0 "$existing_pid" 2>/dev/null; then
+ echo "Memory sampler already running (pid $existing_pid)" >&2
+ exit 0
+ fi
+ rm -f "$PID_FILE"
+ fi
+
+ rm -f "$STOP_FILE"
+ it_memory_clear_sampler_cache "$TARGET_DIR"
+ it_memory_write_samples_header "$SAMPLES_FILE"
+ echo "# started $(date -u +%Y-%m-%dT%H:%M:%SZ) interval=${INTERVAL}s
target=$TARGET_DIR" >> "$LOG_FILE"
+
+ (
+ sampler_loop "$INTERVAL"
+ ) >> "$LOG_FILE" 2>&1 &
+
+ echo $! > "$PID_FILE"
+ echo "Memory sampler started (pid $(cat "$PID_FILE"), interval
${INTERVAL}s)"
+ echo "Samples: $SAMPLES_FILE"
+}
+
+cmd_stop() {
+ sampler_paths
+
+ if [ ! -f "$PID_FILE" ]; then
+ echo "Memory sampler is not running" >&2
+ cmd_summarize || true
+ return 0
+ fi
+
+ pid="$(cat "$PID_FILE" 2>/dev/null || true)"
+ touch "$STOP_FILE"
+
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
+ kill -TERM "$pid" 2>/dev/null || true
+ wait "$pid" 2>/dev/null || true
+ fi
+
+ rm -f "$PID_FILE" "$STOP_FILE"
+ echo "# stopped $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LOG_FILE"
+ cmd_summarize || true
+ echo "Memory sampler stopped"
+}
+
+cmd_sample_once() {
+ sampler_paths
+ it_memory_write_samples_header "$SAMPLES_FILE"
+ it_memory_sample_once "$TARGET_DIR"
+}
+
+cmd_summarize() {
+ sampler_paths
+ if it_memory_summarize_samples "$SAMPLES_FILE" "$SUMMARY_FILE"; then
+ echo "Wrote $SUMMARY_FILE"
+ return 0
+ fi
+ echo "No memory samples to summarize in $SAMPLES_FILE" >&2
+ return 1
+}
+
+cmd_operator_note() {
+ if [ "$PRINT_ONLY" = true ]; then
+ it_generate_operator_note "$TARGET_DIR" "$SCRIPT_DIR"
+ return 0
+ fi
+ if it_write_operator_note_file "$TARGET_DIR" "$SCRIPT_DIR"; then
+ echo "Wrote $TARGET_DIR/$IT_OPERATOR_NOTE_FILE"
+ return 0
+ fi
+ echo "Could not generate operator note (missing $IT_RUN_TRACE in
$TARGET_DIR)" >&2
+ return 1
+}
+
+cmd_verify() {
+ it_memory_verify "$TARGET_DIR" "$SCRIPT_DIR"
+}
+
+main() {
+ parse_args "$@"
+ case "$COMMAND" in
+ start) cmd_start ;;
+ stop) cmd_stop ;;
+ sample-once) cmd_sample_once ;;
+ summarize) cmd_summarize ;;
+ operator-note) cmd_operator_note ;;
+ verify) cmd_verify ;;
+ *)
+ echo "Unknown command: $COMMAND" >&2
+ usage
+ ;;
+ esac
+}
+
+main "$@"
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java
b/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java
index 435360e48..bd7bc231f 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/cluster/ClusterServiceImplTest.java
@@ -383,13 +383,14 @@ public class ClusterServiceImplTest {
persistenceService.save(recentNode);
});
- // Purge items older than cutoff (between old and recent)
- Date cutoff = new Date(System.currentTimeMillis() - 3L * 24 * 3600 *
1000); // 3 days ago
- clusterService.purge(cutoff);
+ // Purge and verify in system tenant — items were saved there;
load/purge are tenant-scoped.
+ executionContextManager.executeAsSystem(() -> {
+ Date cutoff = new Date(System.currentTimeMillis() - 3L * 24 * 3600
* 1000); // 3 days ago
+ clusterService.purge(cutoff);
- // Verify: old node removed, recent node remains
- assertNull(persistenceService.load("old-node", ClusterNode.class));
- assertNotNull(persistenceService.load("recent-node",
ClusterNode.class));
+ assertNull(persistenceService.load("old-node", ClusterNode.class));
+ assertNotNull(persistenceService.load("recent-node",
ClusterNode.class));
+ });
}
@Test
@@ -411,11 +412,11 @@ public class ClusterServiceImplTest {
persistenceService.save(otherNode);
});
- // Execute purge by scope
- clusterService.purge("testScope");
+ executionContextManager.executeAsSystem(() -> {
+ clusterService.purge("testScope");
- // Verify: scoped node removed, other node remains
- assertNull(persistenceService.load("scoped-node", ClusterNode.class));
- assertNotNull(persistenceService.load("other-node",
ClusterNode.class));
+ assertNull(persistenceService.load("scoped-node",
ClusterNode.class));
+ assertNotNull(persistenceService.load("other-node",
ClusterNode.class));
+ });
}
}
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
index 7a31d62ab..593867e2a 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/scheduler/SchedulerServiceImplTest.java
@@ -2033,8 +2033,10 @@ public class SchedulerServiceImplTest {
taskId.set(systemTask.getItemId());
- // Wait for task to execute once
+ // Wait for task to execute once, then cancel immediately so
fixed-rate scheduling
+ // cannot fire a second execution before we assert the count (CI
timing flake).
assertTrue(executionLatch.await(TEST_TIMEOUT, TEST_TIME_UNIT), "Task
should execute");
+ schedulerService.cancelTask(systemTask.getItemId());
assertEquals(1, executionCount.get(), "Task should have executed
once");
// Force persistence to ensure the task is saved
@@ -2485,7 +2487,17 @@ public class SchedulerServiceImplTest {
@Test
@Tag("RecoveryTests")
public void testPreDestroyMarksStaleRunningPersistentTaskAsCrashed()
throws Exception {
- ScheduledTask runningTask = createTestTask("predestroy-crash-test",
ScheduledTask.TaskStatus.RUNNING);
+ // Persist once with a valid lock. createTestTask() saves RUNNING
tasks without a
+ // lock first, which lets the background recovery loop (initial delay
0) mark them
+ // CRASHED before preDestroy() under CI scheduling.
+ ScheduledTask runningTask = new ScheduledTask();
+ runningTask.setItemId(UUID.randomUUID().toString());
+ runningTask.setTaskType("predestroy-crash-test");
+ runningTask.setStatus(ScheduledTask.TaskStatus.RUNNING);
+ runningTask.setLockOwner(schedulerService.getNodeId());
+ runningTask.setLockDate(new Date());
+ runningTask.setExecutingNodeId(schedulerService.getNodeId());
+ persistenceService.save(runningTask);
persistenceService.refresh();
schedulerService.preDestroy();