This is an automated email from the ASF dual-hosted git repository.
janhoy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-orbit.git
The following commit(s) were added to refs/heads/main by this push:
new e132cbbd Convert OpenSearch's index-stats merge wait to Solr's
wait-for-merges (#80)
e132cbbd is described below
commit e132cbbdc8216a4a6c04892940bc32b6c078757a
Author: Serhiy Bzhezytskyy <[email protected]>
AuthorDate: Thu Oct 15 10:16:17 2026 +0300
Convert OpenSearch's index-stats merge wait to Solr's wait-for-merges (#80)
---
docs/converter/what-converts.md | 1 +
solrorbit/conversion/workload_converter.py | 92 +++++++++++++++-
tests/unit/solr/test_workload_converter.py | 164 +++++++++++++++++++++++++++++
3 files changed, 255 insertions(+), 2 deletions(-)
diff --git a/docs/converter/what-converts.md b/docs/converter/what-converts.md
index f7b064ef..fdaeeed2 100644
--- a/docs/converter/what-converts.md
+++ b/docs/converter/what-converts.md
@@ -23,6 +23,7 @@ The table below summarizes which OpenSearch Benchmark
constructs are automatical
| `terms` aggregations | Converted to Solr `terms` facet |
| `date_histogram` aggregations | Converted to Solr `range` facet with
calendar gap |
| `avg` / `sum` / `min` / `max` aggregations | Converted to Solr function
query stats |
+| `index-stats` retried until `merges.current` reaches zero | →
`wait-for-merges`, with the polling expressed as `retry-wait-period` and
`max-wait-seconds` |
## Requires manual review
diff --git a/solrorbit/conversion/workload_converter.py
b/solrorbit/conversion/workload_converter.py
index b99dcb6d..025fbd8b 100644
--- a/solrorbit/conversion/workload_converter.py
+++ b/solrorbit/conversion/workload_converter.py
@@ -209,6 +209,18 @@ _UNSUPPORTED_OPS = {
"delete-pipeline",
}
+# An index-stats operation polling this condition path is a wait-for-merges in
Solr
+_MERGE_WAIT_CONDITION_PATH = "merges.current"
+_MERGE_WAIT_RETRY_WAIT_PERIOD = 2.0
+_MERGE_WAIT_MAX_WAIT_SECONDS = 600
+
+_MERGE_WAIT_OBJECT = re.compile(
+ r'\{[^{}]*?"operation-type"\s*:\s*"index-stats"\s*,'
+ r'[^{}]*?"condition"\s*:\s*\{[^{}]*?"path"\s*:\s*"[^"]*'
+ + re.escape(_MERGE_WAIT_CONDITION_PATH)
+ + r'"[^{}]*?\}[^{}]*?\}'
+)
+
def detect_workload_format_from_file(workload_dir: str) -> bool:
"""
@@ -455,7 +467,12 @@ def _process_collected_files(source_dir: str, output_dir:
str, issues: list, ski
ops_list, tokens = _parse_jinja_fragment(raw, wrap_array=True)
except ValueError as exc:
issues.append(f"{subdir}/{filename}: cannot parse as JSON
fragment ({exc}); copied verbatim")
- shutil.copy2(src_path, dst_path)
+ merges_converted = _convert_merge_wait_text(raw)
+ if merges_converted == raw:
+ shutil.copy2(src_path, dst_path)
+ else:
+ with open(dst_path, "w", encoding="utf-8") as f:
+ f.write(merges_converted)
continue
# Convert each operation in the fragment; filter out skipped ones
@@ -552,6 +569,73 @@ def _has_auto_date_histogram(aggs: dict) -> bool:
return False
+def _is_merge_wait(op: dict) -> bool:
+ """
+ Return True if *op* is OpenSearch Benchmark's "wait until merges finish"
idiom:
+ an index-stats operation retried until the active merge count reaches zero.
+ """
+ condition = op.get("condition")
+ if not isinstance(condition, dict):
+ return False
+ path = condition.get("path")
+ if not isinstance(path, str) or not
path.endswith(_MERGE_WAIT_CONDITION_PATH):
+ return False
+ return condition.get("expected-value") == 0
+
+
+def _convert_merge_wait(op: dict) -> None:
+ """
+ Rewrite *op* in-place as a Solr ``wait-for-merges`` operation.
+
+ The polling OpenSearch expresses with ``condition`` and
``retry-until-success`` is
+ the runner's own loop in Solr, so those keys are replaced by its wait
parameters.
+ """
+ converted = {}
+ if "name" in op:
+ converted["name"] = op["name"]
+ converted["operation-type"] = "wait-for-merges"
+ converted["retry-wait-period"] = _MERGE_WAIT_RETRY_WAIT_PERIOD
+ converted["max-wait-seconds"] = _MERGE_WAIT_MAX_WAIT_SECONDS
+ if "include-in-reporting" in op:
+ converted["include-in-reporting"] = op["include-in-reporting"]
+
+ replaced = {"name", "operation-type", "type", "index", "indices",
"condition",
+ "retry-until-success", "include-in-reporting"}
+ converted.update({key: value for key, value in op.items() if key not in
replaced})
+
+ op.clear()
+ op.update(converted)
+
+
+def _convert_merge_wait_text(text: str) -> str:
+ """
+ Rewrite every merge-wait operation object in *text* as ``wait-for-merges``.
+
+ Used for fragments whose Jinja2 directives keep them from being parsed as
JSON,
+ where the operation object itself is still plain JSON.
+ """
+ def replace(match):
+ matched = match.group(0)
+ try:
+ op = json.loads(matched)
+ except json.JSONDecodeError:
+ return matched
+ if not isinstance(op, dict) or not _is_merge_wait(op):
+ return matched
+
+ _convert_merge_wait(op)
+ line_start = text.rfind("\n", 0, match.start()) + 1
+ indent = text[line_start:match.start()]
+ indent = indent[:len(indent) - len(indent.lstrip())]
+
+ first_key = re.match(r"\{\s*\n([ \t]*)", matched)
+ step = len(first_key.group(1)) - len(indent) if first_key else 4
+ rendered = json.dumps(op, indent=max(step, 1))
+ return rendered.replace("\n", "\n" + indent)
+
+ return _MERGE_WAIT_OBJECT.sub(replace, text)
+
+
def _convert_operation(op, issues, skipped, source_dir, output_dir):
"""Convert an operation definition dict in-place.
@@ -560,6 +644,10 @@ def _convert_operation(op, issues, skipped, source_dir,
output_dir):
op_type = op.get("operation-type") or op.get("type", "")
op_name = op.get("name", op_type)
+ if op_type == "index-stats" and _is_merge_wait(op):
+ _convert_merge_wait(op)
+ return True
+
if op_type in _UNSUPPORTED_OPS:
logger.warning("Skipping unsupported operation '%s' (type: %s)",
op_name, op_type)
skipped.append(op_name)
@@ -752,7 +840,7 @@ def _process_external_collected_files(source_dir: str,
output_dir: str, issues:
return _serialise_jinja_fragment(ops_list, tokens, wrap_array=True)
except ValueError:
# Complex Jinja2 — fall back to text substitution for known
op-type strings
- result = raw
+ result = _convert_merge_wait_text(raw)
for old_op, new_op in _OP_MAP.items():
if old_op != new_op:
result = re.sub(
diff --git a/tests/unit/solr/test_workload_converter.py
b/tests/unit/solr/test_workload_converter.py
index 480117bd..29640f7e 100644
--- a/tests/unit/solr/test_workload_converter.py
+++ b/tests/unit/solr/test_workload_converter.py
@@ -24,6 +24,9 @@ import unittest
from solrorbit.conversion.workload_converter import (
CONVERTED_MARKER,
+ _convert_merge_wait,
+ _convert_merge_wait_text,
+ _convert_operation,
convert_opensearch_workload,
detect_workload_format_from_file,
is_already_converted,
@@ -466,5 +469,166 @@ class TestDateHistogramFacetGap(unittest.TestCase):
self.assertEqual("+1MONTH", gap)
+class TestMergeWaitConversion(unittest.TestCase):
+ """Tests for OpenSearch's index-stats merge wait becoming Solr's
wait-for-merges."""
+
+ def _osb_merge_wait(self):
+ return {
+ "name": "wait-until-merges-finish",
+ "operation-type": "index-stats",
+ "index": "_all",
+ "condition": {"path": "_all.total.merges.current",
"expected-value": 0},
+ "retry-until-success": True,
+ "include-in-reporting": False,
+ }
+
+ def test_converted_operation_is_the_solr_operation(self):
+ op = self._osb_merge_wait()
+ _convert_merge_wait(op)
+ self.assertEqual({
+ "name": "wait-until-merges-finish",
+ "operation-type": "wait-for-merges",
+ "retry-wait-period": 2.0,
+ "max-wait-seconds": 600,
+ "include-in-reporting": False,
+ }, op)
+
+ def test_convert_operation_leaves_other_index_stats_alone(self):
+ op = {"name": "index-stats", "operation-type": "index-stats"}
+ _convert_operation(op, [], [], "", "")
+ self.assertEqual("index-stats", op["operation-type"])
+
+ def test_convert_operation_leaves_another_condition_path_alone(self):
+ op = self._osb_merge_wait()
+ op["condition"]["path"] = "_all.total.docs.count"
+ _convert_operation(op, [], [], "", "")
+ self.assertEqual("index-stats", op["operation-type"])
+
+ def test_text_conversion_of_a_jinja_fragment(self):
+ raw = """{
+ "operation": {
+ "operation-type": "force-merge",
+ "request-timeout": {{ request_timeout | default(60) | tojson }}
+ }
+},
+{
+ "name": "wait-until-merges-finish",
+ "operation": {
+ "operation-type": "index-stats",
+ "index": "_all",
+ "condition": {
+ "path": "_all.total.merges.current",
+ "expected-value": 0
+ },
+ "retry-until-success": true,
+ "include-in-reporting": false
+ }
+}"""
+ converted = _convert_merge_wait_text(raw)
+ self.assertNotIn("index-stats", converted)
+ self.assertIn('"operation-type": "wait-for-merges"', converted)
+ self.assertIn('"retry-wait-period": 2.0', converted)
+ self.assertIn('"max-wait-seconds": 600', converted)
+ self.assertIn("{{ request_timeout | default(60) | tojson }}",
converted)
+
+ def test_text_conversion_follows_the_fragment_indentation(self):
+ template = """{{
+{i}"name": "wait-until-merges-finish",
+{i}"operation": {{
+{i}{i}"operation-type": "index-stats",
+{i}{i}"index": "_all",
+{i}{i}"condition": {{
+{i}{i}{i}"path": "_all.total.merges.current",
+{i}{i}{i}"expected-value": 0
+{i}{i}}},
+{i}{i}"include-in-reporting": false
+{i}}}
+}}"""
+ for indent in (" ", " "):
+ with self.subTest(indent=len(indent)):
+ converted = _convert_merge_wait_text(template.format(i=indent))
+ self.assertIn(f'\n{indent * 2}"operation-type":
"wait-for-merges",', converted)
+ self.assertIn(f"\n{indent}}}\n}}", converted)
+
+ def test_text_conversion_leaves_another_condition_alone(self):
+ raw = """{
+ "operation": {
+ "operation-type": "index-stats",
+ "index": "_all",
+ "condition": {
+ "path": "_all.total.docs.count",
+ "expected-value": 0
+ }
+ }
+}"""
+ self.assertEqual(raw, _convert_merge_wait_text(raw))
+
+ def test_conversion_of_an_external_fragment_end_to_end(self):
+ """The merge wait reaches the output even from a fragment Jinja2 keeps
unparseable."""
+ fragment = """{
+ "operation": {
+ "operation-type": "force-merge",
+ "request-timeout": {{ request_timeout | default(60) | tojson }}{%- if
max_num_segments is defined %},
+ "max-num-segments": {{ max_num_segments | tojson }}
+ {%- endif %}
+ }
+},
+{
+ "name": "wait-until-merges-finish",
+ "operation": {
+ "operation-type": "index-stats",
+ "index": "_all",
+ "condition": {
+ "path": "_all.total.merges.current",
+ "expected-value": 0
+ },
+ "retry-until-success": true,
+ "include-in-reporting": false
+ }
+}"""
+ with tempfile.TemporaryDirectory() as tmpdir,
tempfile.TemporaryDirectory() as dst:
+ shared = os.path.join(tmpdir, "common_operations")
+ os.makedirs(shared)
+ with open(os.path.join(shared, "force_merge.json"), "w") as f:
+ f.write(fragment)
+
+ src = os.path.join(tmpdir, "my_workload")
+ os.makedirs(os.path.join(src, "test_procedures"))
+ with open(os.path.join(src, "workload.json"), "w") as f:
+ json.dump({"indices": [], "challenges": []}, f)
+ with open(os.path.join(src, "test_procedures", "default.json"),
"w") as f:
+ f.write('{\n "name": "default",\n "schedule": [\n'
+ ' {{
benchmark.collect(parts="../../common_operations/force_merge.json") }}\n'
+ " ]\n}")
+
+ convert_opensearch_workload(src, dst)
+
+ with open(os.path.join(dst, "common_operations",
"force_merge.json")) as f:
+ out = f.read()
+ self.assertNotIn("index-stats", out)
+ self.assertIn('"operation-type": "wait-for-merges"', out)
+ self.assertIn('"max-wait-seconds": 600', out)
+
+ def test_conversion_end_to_end(self):
+ with tempfile.TemporaryDirectory() as src,
tempfile.TemporaryDirectory() as dst:
+ with open(os.path.join(src, "workload.json"), "w") as f:
+ json.dump({
+ "indices": [],
+ "challenges": [
+ {
+ "name": "default",
+ "schedule": [{"operation":
self._osb_merge_wait()}],
+ }
+ ],
+ }, f)
+ convert_opensearch_workload(src, dst)
+ with open(os.path.join(dst, "workload.json")) as f:
+ out = json.load(f)
+ op = out["challenges"][0]["schedule"][0]["operation"]
+ self.assertEqual("wait-for-merges", op["operation-type"])
+ self.assertEqual(600, op["max-wait-seconds"])
+ self.assertNotIn("condition", op)
+
+
if __name__ == "__main__":
unittest.main()