This is an automated email from the ASF dual-hosted git repository. ab pushed a commit to branch jira/solr-15232 in repository https://gitbox.apache.org/repos/asf/solr.git
commit 2cdfc69a5eff3092a5ba9d588877d8e9edbb9634 Author: Andrzej Bialecki <[email protected]> AuthorDate: Tue Apr 6 14:43:24 2021 +0200 SOLRR-15232: Add moroe commands to the DSL, hook up the CC shutdown, add unit tests. --- .../java/org/apache/solr/core/CoreContainer.java | 29 +- .../org/apache/solr/util/scripting/Script.java | 709 +++++++++++++++------ solr/core/src/test-files/solr/initScript.txt | 19 + solr/core/src/test-files/solr/initScript2.txt | 4 + solr/core/src/test-files/solr/shutdownScript.txt | 1 + .../apache/solr/util/scripting/ScriptingTest.java | 89 ++- 6 files changed, 622 insertions(+), 229 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/core/CoreContainer.java b/solr/core/src/java/org/apache/solr/core/CoreContainer.java index e6e635a..942a320 100644 --- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java +++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java @@ -939,11 +939,11 @@ public class CoreContainer { if (isZooKeeperAware()) { // run init script if needed String resourceName = System.getProperty(Script.INIT_SCRIPT); - runScript(resourceName, true); + runScript(resourceName, false); } } - public void runScript(String resourceName, boolean async) { + public void runScript(String resourceName, boolean inShutdown) { if (resourceName == null) { return; } @@ -951,28 +951,21 @@ public class CoreContainer { String nodeName = zkSys.zkController.getNodeName(); Script script = null; try { - script = Script.loadResource(loader, solrClient, nodeName, resourceName); + script = Script.parseResource(loader, solrClient, nodeName, resourceName); } catch (Exception e) { log.warn("Error loading script, ignoring " + resourceName, e); script = null; } final Script finalScript = script; if (finalScript != null) { - Runnable r = () -> { - try { - finalScript.run(); - } catch (Exception e) { - log.warn("Error running script " + resourceName, e); - if (finalScript.getErrorHandling() == Script.ErrorHandling.FATAL) { - log.warn("Script error handling set to FATAL - shutting down."); - shutdown(); - } + try { + finalScript.run(); + } catch (Exception e) { + log.warn("Error running script " + resourceName, e); + if (!inShutdown && finalScript.getErrorHandling() == Script.ErrorHandling.FATAL) { + log.warn("Script error handling set to FATAL - shutting down."); + shutdown(); } - }; - if (async) { - runAsync(r); - } else { - r.run(); } } } @@ -1074,7 +1067,7 @@ public class CoreContainer { overseerCollectionQueue.allowOverseerPendingTasksToComplete(); // run shutdown script if needed String resourceName = System.getProperty(Script.SHUTDOWN_SCRIPT); - runScript(resourceName, false); + runScript(resourceName, true); } if (log.isInfoEnabled()) { log.info("Shutting down CoreContainer instance={}", System.identityHashCode(this)); diff --git a/solr/core/src/java/org/apache/solr/util/scripting/Script.java b/solr/core/src/java/org/apache/solr/util/scripting/Script.java index d02322d..ab8d167 100644 --- a/solr/core/src/java/org/apache/solr/util/scripting/Script.java +++ b/solr/core/src/java/org/apache/solr/util/scripting/Script.java @@ -18,6 +18,7 @@ package org.apache.solr.util.scripting; import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.cloud.SolrCloudManager; import org.apache.solr.client.solrj.impl.CloudSolrClient; @@ -26,44 +27,48 @@ import org.apache.solr.client.solrj.impl.SolrClientCloudManager; import org.apache.solr.client.solrj.request.GenericSolrRequest; import org.apache.solr.client.solrj.request.RequestWriter; import org.apache.solr.cloud.CloudUtil; +import org.apache.solr.common.MapWriter; import org.apache.solr.common.cloud.ClusterState; +import org.apache.solr.common.cloud.DocCollection; +import org.apache.solr.common.cloud.Replica; import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.params.SolrParams; import org.apache.solr.common.util.ExecutorUtil; -import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.PropertiesUtil; import org.apache.solr.common.util.SolrNamedThreadFactory; import org.apache.solr.common.util.TimeSource; import org.apache.solr.common.util.Utils; import org.apache.solr.core.SolrResourceLoader; +import org.apache.solr.util.TimeOut; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.PrintStream; import java.lang.invoke.MethodHandles; import java.net.URLDecoder; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.Properties; import java.util.Random; +import java.util.Stack; import java.util.TreeMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.Function; /** * This class represents a script consisting of a series of operations. */ -public class Script implements AutoCloseable { +public class Script implements Closeable { private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); public static final String INIT_SCRIPT = "initScript"; @@ -78,18 +83,18 @@ public class Script implements AutoCloseable { public static final String LIVE_NODES_CTX_PROP = "_live_nodes_"; /** Context variable: List of collections. */ public static final String COLLECTIONS_CTX_PROP = "_collections_"; - /** Context variable: List of SolrResponses of SOLR_REQUEST operations. */ - public static final String RESPONSES_CTX_PROP = "_responses_"; + /** Context variable: Last op result. */ + public static final String LAST_RESULT_CTX_PROP = "_last_result_"; /** Context variable: Current loop iteration or none if outside of loop. */ public static final String LOOP_ITER_PROP = "_loop_iter_"; /** Context variable: Timeout for operation to complete, in ms. */ public static final String TIMEOUT_PROP = "_timeout_"; /** Context variable: How to handle errors. */ - public static final String ERROR_HANDLING_PROP = "errorHandling"; + public static final String ERROR_HANDLING_PROP = "_error_handling_"; public enum ErrorHandling { IGNORE, - END_SCRIPT, + ABORT, FATAL; public static ErrorHandling get(String str) { @@ -105,7 +110,7 @@ public class Script implements AutoCloseable { } } - public static final int DEFAULT_OP_TIMEOUT_MS = 30000; + public static final int DEFAULT_OP_TIMEOUT_MS = 120000; public final List<ScriptOp> ops = new ArrayList<>(); public final Map<String, Object> context = new HashMap<>(); @@ -113,15 +118,27 @@ public class Script implements AutoCloseable { public final String nodeName; public final SolrCloudManager cloudManager; public final TimeSource timeSource = TimeSource.NANO_TIME; + public final Random random = new Random(); + public final List<OpResult> opResults = new ArrayList<>(); + private ExecutorService executorService; + private final boolean shouldCloseExecutor; public PrintStream console = System.err; public boolean verbose; public boolean abortLoop; public boolean abortScript; - public ErrorHandling errorHandling = ErrorHandling.END_SCRIPT; - public final Random random = new Random(); + public ErrorHandling errorHandling = ErrorHandling.ABORT; + + public Function<String, String> propertyEvalFunc = (key) -> { + Object o = Utils.getObjectByPath(context, false, key); + if (o != null) { + return o.toString(); + } else { + return null; + } + }; /** Base class for implementation of script DSL ScriptActions. */ - public static abstract class ScriptOp { + public static abstract class ScriptOp implements MapWriter { ModifiableSolrParams initParams; ModifiableSolrParams params; @@ -138,37 +155,9 @@ public class Script implements AutoCloseable { */ @SuppressWarnings({"unchecked"}) public void prepareCurrentParams(Script script) { - Properties props = new Properties(); - script.context.forEach((k, v) -> { - if (v instanceof String[]) { - v = String.join(",", (String[]) v); - } else if (v instanceof Collection) { - StringBuilder sb = new StringBuilder(); - for (Object o : (Collection<Object>)v) { - if (sb.length() > 0) { - sb.append(','); - } - if ((o instanceof String) || (o instanceof Number)) { - sb.append(o); - } else { - // skip all values - return; - } - } - v = sb.toString(); - } else if ((v instanceof String)) { - // don't convert, put as is - } else if ((v instanceof Number)) { - v = v.toString(); - } else { - // skip - return; - } - props.put(k, v); - }); ModifiableSolrParams currentParams = new ModifiableSolrParams(); initParams.forEach(e -> { - String newKey = PropertiesUtil.substituteProperty(e.getKey(), props); + String newKey = PropertiesUtil.substitute(e.getKey(), script.propertyEvalFunc); if (newKey == null) { newKey = e.getKey(); } @@ -177,7 +166,7 @@ public class Script implements AutoCloseable { String[] values = e.getValue(); newValues = new String[values.length]; for (int k = 0; k < values.length; k++) { - String newVal = PropertiesUtil.substituteProperty(values[k], props); + String newVal = PropertiesUtil.substitute(values[k], script.propertyEvalFunc); if (newVal == null) { newVal = values[k]; } @@ -194,12 +183,38 @@ public class Script implements AutoCloseable { /** * Execute the operation. * @param script current script. + * @return result of the operation (status, response, etc) for informative purpose */ - public abstract void execute (Script script) throws Exception; + public abstract Object execute (Script script) throws Exception; @Override public String toString() { - return this.getClass().getSimpleName() + "{" + (params != null ? params : initParams) + "}"; + return this.getClass().getSimpleName() + "{" + initParams + "}"; + } + + @Override + public void writeMap(EntryWriter ew) throws IOException { + ew.put("name", getClass().getSimpleName()); + ew.put("initParams", initParams); + if (params != null) { + ew.put("params", params); + } + } + } + + public static class OpResult implements MapWriter { + public final ScriptOp op; + public final Object result; + + public OpResult(ScriptOp op, Object result) { + this.op = op; + this.result = result; + } + + @Override + public void writeMap(EntryWriter ew) throws IOException { + ew.put("op", op); + ew.put("result", result); } } @@ -209,13 +224,19 @@ public class Script implements AutoCloseable { */ public enum ScriptAction { /** Start a loop. */ - LOOP_START, - /** End a loop. */ - LOOP_END, + LOOP, + /** End a loop of if/else. */ + END, + /** Start a conditional block. */ + IF, + /** Start an ELSE conditional block. */ + ELSE, /** Execute a SolrRequest. */ - SOLR_REQUEST, + REQUEST, /** Wait for a collection to reach the indicated number of shards and replicas. */ WAIT_COLLECTION, + /** Wait for a replica (or core) to reach the indicated state. */ + WAIT_REPLICA, /** Prepare a listener to listen for an autoscaling event. */ EVENT_LISTENER, /** Wait for an autoscaling event using previously prepared listener. */ @@ -230,6 +251,8 @@ public class Script implements AutoCloseable { ASSERT, /** Set an operation timeout. */ TIMEOUT, + /** Log arbitrary data from context. */ + LOG, /** Dump the script and the current context to the log. */ DUMP; @@ -252,22 +275,37 @@ public class Script implements AutoCloseable { public static Map<ScriptAction, Class<? extends ScriptOp>> supportedOps = new HashMap<>(); static { - supportedOps.put(ScriptAction.LOOP_START, LoopOp.class); - supportedOps.put(ScriptAction.LOOP_END, null); - supportedOps.put(ScriptAction.SOLR_REQUEST, RunSolrRequest.class); - supportedOps.put(ScriptAction.WAIT, Wait.class); - supportedOps.put(ScriptAction.WAIT_COLLECTION, WaitCollection.class); - supportedOps.put(ScriptAction.SET, CtxSet.class); - supportedOps.put(ScriptAction.CLEAR, CtxClear.class); - supportedOps.put(ScriptAction.ASSERT, Assert.class); - supportedOps.put(ScriptAction.TIMEOUT, Timeout.class); - supportedOps.put(ScriptAction.DUMP, Dump.class); + supportedOps.put(ScriptAction.LOOP, LoopOp.class); + supportedOps.put(ScriptAction.REQUEST, RequestOp.class); + supportedOps.put(ScriptAction.WAIT, WaitOp.class); + supportedOps.put(ScriptAction.WAIT_COLLECTION, WaitCollectionOp.class); + supportedOps.put(ScriptAction.WAIT_REPLICA, WaitReplicaOp.class); + supportedOps.put(ScriptAction.SET, CtxSetOp.class); + supportedOps.put(ScriptAction.CLEAR, CtxClearOp.class); + supportedOps.put(ScriptAction.ASSERT, AssertOp.class); + supportedOps.put(ScriptAction.TIMEOUT, TimeoutOp.class); + supportedOps.put(ScriptAction.LOG, LogOp.class); + supportedOps.put(ScriptAction.IF, IfElseOp.class); + supportedOps.put(ScriptAction.ELSE, null); + supportedOps.put(ScriptAction.END, null); + supportedOps.put(ScriptAction.DUMP, DumpOp.class); + } + + private Script(CloudSolrClient client, String nodeName) { + this(client, nodeName, null); } - - public Script(CloudSolrClient client, String nodeName) { + + private Script(CloudSolrClient client, String nodeName, ExecutorService executorService) { this.client = client; this.nodeName = nodeName; this.cloudManager = new SolrClientCloudManager(null, client); + if (executorService == null) { + this.executorService = ExecutorUtil.newMDCAwareSingleThreadExecutor(new SolrNamedThreadFactory("Script")); + this.shouldCloseExecutor = true; + } else { + this.executorService = executorService; + this.shouldCloseExecutor = false; + } // We make things reproducible in tests by using test seed if any String seed = System.getProperty("tests.seed"); if (seed != null) { @@ -276,87 +314,178 @@ public class Script implements AutoCloseable { } /** - * Loop ScriptAction. + * Log some information. */ - public static class LoopOp extends ScriptOp { + public static class LogOp extends ScriptOp { + + @Override + public Object execute(Script script) throws Exception { + String msg = params.get("msg"); + if (msg == null) { + String[] keys = params.required().getParams("key"); + String format = params.required().get("format"); + int count = StringUtils.countMatches(format, "{}"); + if (count > keys.length) { + throw new Exception("Too few key names for the number of formal parameters: " + count); + } else if (count < keys.length) { + throw new Exception("Too many key names for the number of formal parameters: " + count); + } + Object[] vals = new Object[keys.length]; + for (int i = 0; i < keys.length; i++) { + vals[i] = Utils.getObjectByPath(script.context, false, keys[i]); + } + log.info(format, vals); + } else { + log.info(msg); + } + return null; + } + } + + public static abstract class CompoundOp extends ScriptOp { // populated by the DSL parser - List<ScriptOp> ops = new ArrayList<>(); + public List<ScriptOp> ops = new ArrayList<>(); + + public void addOp(ScriptOp op) { + ops.add(op); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(this.getClass().getSimpleName() + "{" + params + ", ops=" + ops.size() + "}"); + for (int i = 0; i < ops.size(); i++) { + sb.append("\n " + (i + 1) + ". " + ops.get(i)); + } + return sb.toString(); + } + } + + /** + * Loop ScriptAction. + */ + public static class LoopOp extends CompoundOp { int iterations; @Override - public void execute(Script script) throws Exception { - iterations = Integer.parseInt(params.get("iterations", "10")); + public Object execute(Script script) throws Exception { + iterations = params.required().getInt("iterations", 10); + if (iterations < 0) { + throw new Exception("Number of iterations must be non-negative but was " + iterations); + } for (int i = 0; i < iterations; i++) { if (script.abortLoop) { log.info(" -- abortLoop requested, aborting after {} iterations.", i); - return; + return Map.of("iterations", i, "state", "aborted"); } script.context.put(LOOP_ITER_PROP, String.valueOf(i)); - log.info(" * iter {} :", i + 1); // logOK + log.debug(" * iter {} :", i + 1); // logOK for (ScriptOp op : ops) { - op.prepareCurrentParams(script); if (log.isInfoEnabled()) { log.info(" - {}\t{})", op.getClass().getSimpleName(), op.params); } - op.execute(script); + script.execOp(op); if (script.abortLoop) { log.info(" -- abortLoop requested, aborting after {} iterations.", i); - return; + return Map.of("iterations", i, "state", "aborted"); } } } + return Map.of("iterations", iterations, "state", "finished"); } + } + + public static class IfElseOp extends CompoundOp { + public List<ScriptOp> elseOps = new ArrayList<>(); + public boolean parsingElse = false; @Override - public String toString() { - StringBuilder sb = new StringBuilder(this.getClass().getSimpleName() + "{" + params + ", ops=" + ops.size()); - for (int i = 0; i < ops.size(); i++) { - sb.append("\n " + (i + 1) + ". " + ops.get(i)); + public void addOp(ScriptOp op) { + if (parsingElse) { + elseOps.add(op); + } else { + ops.add(op); } - return sb.toString(); + } + + @Override + public Object execute(Script script) throws Exception { + String key = params.get("key"); + Condition condition = Condition.get(params.required().get("condition")); + if (condition == null) { + throw new IOException("Invalid 'condition' in params: " + params); + } + String expected = params.get("expected"); + if (condition != Condition.NOT_NULL && condition != Condition.NULL && expected == null) { + throw new IOException("'expected' param is required when condition is " + condition); + } + Object value; + if (key != null) { + value = Utils.getObjectByPath(script.context, true, key); + } else { + value = params.required().get("value"); + } + boolean eval = Condition.eval(condition, value, expected); + List<ScriptOp> selectedOps = eval ? ops : elseOps; + for (ScriptOp op : selectedOps) { + if (log.isInfoEnabled()) { + log.info(" - {}\t{})", op.getClass().getSimpleName(), op.params); + } + script.execOp(op); + } + return Map.of("condition_eval", eval); } } /** * Set a context property. */ - public static class CtxSet extends ScriptOp { + public static class CtxSetOp extends ScriptOp { @Override - public void execute(Script script) throws Exception { + public Object execute(Script script) throws Exception { String key = params.required().get("key"); - String[] values = params.required().getParams("value"); - if (values != null) { - script.context.put(key, Arrays.asList(values)); + String value = params.required().get("value"); + Object previous; + if (value != null) { + previous = script.context.put(key, value); } else { - script.context.remove(key); + previous = script.context.remove(key); } + Map<String, Object> res = new HashMap<>(); + res.put("previous", previous); + res.put("new", value); + return res; } } /** * Remove a context property. */ - public static class CtxClear extends ScriptOp { + public static class CtxClearOp extends ScriptOp { @Override - public void execute(Script script) throws Exception { + public Object execute(Script script) throws Exception { String key = params.required().get("key"); - script.context.remove(key); + return script.context.remove(key); } } /** - * Dump the script and the current context to log output. + * Dump the script, execution trace and the current context to log output. */ - public static class Dump extends ScriptOp { + public static class DumpOp extends ScriptOp { @Override - public void execute(Script script) throws Exception { + public Object execute(Script script) throws Exception { log.info("========= START script dump =========="); - log.info("--------- Script Ops ----------"); + log.info("------ Script Operations --------"); for (int i = 0; i < script.ops.size(); i++) { - log.info("{}. {}", i + 1, script.ops.get(i)); + log.info("{}.\t{}", i + 1, script.ops.get(i)); + } + log.info("------ Script Execution --------"); + for (int i = 0; i < script.opResults.size(); i++) { + OpResult res = script.opResults.get(i); + dumpResult(res, i + 1, 0); } - log.info("--------- Script Context ----------"); + log.info("------ Final Script Context --------"); TreeMap<String, Object> map = new TreeMap<>(script.context); map.forEach((key, value) -> { if (value instanceof Collection) { @@ -374,19 +503,62 @@ public class Script implements AutoCloseable { log.info("{} size={}", key, m.size()); m.forEach((k, v) -> log.info("\t{}\t{}", k, v)); } else { - log.info("{}\t{}", key, value); + if (value instanceof OpResult) { + dumpResult((OpResult) value, 0, 0); + } else { + log.info("{}\t{}", key, value); + } } }); log.info("========= END script dump =========="); + return null; + } + + private void dumpResult(OpResult res, int index, int indent) { + StringBuilder indentSb = new StringBuilder(); + for (int i = 0; i < indent; i++) { + indentSb.append('\t'); + } + String indentStr = indentSb.toString(); + log.info("{}{}.\t{}", indentStr, index, res.op); + if (res.result == null) { + return; + } + if (res.result instanceof Collection) { + @SuppressWarnings("unchecked") + Collection<Object> col = (Collection<Object>) res.result; + log.info("{}\tRes: size={}", indentStr, col.size()); + int k = 1; + for (Object o : col) { + if (o instanceof OpResult) { // LOOP op results + dumpResult((OpResult) o, k, indent + 1); + } else { + log.info("{}\t-\t{}. {}", indentStr, k, o); + } + k++; + } + } else if (res.result instanceof Map) { + @SuppressWarnings("unchecked") + Map<String, Object> map = (Map<String, Object>) res.result; + log.info("{}\tRes: size={}", indentStr, map.size()); + map.forEach((k, v) -> log.info("{}\t-\t{}\t{}", indentStr, k, v)); + } else if (res.result instanceof MapWriter) { + @SuppressWarnings("unchecked") + MapWriter mw = (MapWriter) res.result; + log.info("{}\tRes: size={}", indentStr, mw._size()); + log.info("{}\t{}", indentStr, mw.jsonStr()); + } else { + log.info("{}\tRes: {}", indentStr, res.result); + } } } /** * Execute a SolrRequest. */ - public static class RunSolrRequest extends ScriptOp { + public static class RequestOp extends ScriptOp { @Override - public void execute(Script script) throws Exception { + public Object execute(Script script) throws Exception { String path = params.get("path", "/"); SolrRequest.METHOD m = SolrRequest.METHOD.valueOf(params.get("httpMethod", "GET")); params.remove("httpMethod"); @@ -396,53 +568,127 @@ public class Script implements AutoCloseable { if (streamBody != null) { req.setContentWriter(new RequestWriter.StringPayloadContentWriter(streamBody, "application/json")); } - NamedList<Object> rsp = script.client.request(req); - @SuppressWarnings("unchecked") - List<NamedList<Object>> responses = (List<NamedList<Object>>) script.context.computeIfAbsent(RESPONSES_CTX_PROP, o -> new ArrayList<NamedList<Object>>()); - responses.add(rsp); + return script.client.request(req); } } /** * Set (or reset) the timeout for operations to execute. */ - public static class Timeout extends ScriptOp { + public static class TimeoutOp extends ScriptOp { @Override - public void execute(Script script) throws Exception { + public Object execute(Script script) throws Exception { String value = params.get("value", "" + DEFAULT_OP_TIMEOUT_MS); long newTimeout = DEFAULT_OP_TIMEOUT_MS; if (!value.equals("default")) { newTimeout = Long.parseLong(value); } script.context.put(TIMEOUT_PROP, newTimeout); + return null; } } /** * Sit idle for a while, 10s by default. */ - public static class Wait extends ScriptOp { + public static class WaitOp extends ScriptOp { @Override - public void execute(Script script) throws Exception { + public Object execute(Script script) throws Exception { int timeMs = params.getInt("time", 10000); script.timeSource.sleep(timeMs); + return null; } } /** * Wait for a specific collection shape. */ - public static class WaitCollection extends ScriptOp { + public static class WaitCollectionOp extends ScriptOp { @Override - public void execute(Script script) throws Exception { + public Object execute(Script script) throws Exception { String collection = params.required().get("collection"); int shards = Integer.parseInt(params.required().get("shards")); int replicas = Integer.parseInt(params.required().get("replicas")); boolean withInactive = params.getBool("withInactive", false); boolean requireLeaders = params.getBool("requireLeaders", true); int waitSec = params.required().getInt("wait", CloudUtil.DEFAULT_TIMEOUT); - CloudUtil.waitForState(script.cloudManager, collection, waitSec, TimeUnit.SECONDS, - CloudUtil.clusterShape(shards, replicas, withInactive, requireLeaders)); + try { + CloudUtil.waitForState(script.cloudManager, collection, waitSec, TimeUnit.SECONDS, + CloudUtil.clusterShape(shards, replicas, withInactive, requireLeaders)); + } catch (Exception e) { + DocCollection coll = script.cloudManager.getClusterStateProvider().getCollection(collection); + throw new Exception("last collection state: " + coll, e); + } + DocCollection coll = script.cloudManager.getClusterStateProvider().getCollection(collection); + return Utils.fromJSONString(Utils.toJSONString(coll)); + } + } + + /** + * Wait for specific core to reach a state. + */ + public static class WaitReplicaOp extends ScriptOp { + @Override + public Object execute(Script script) throws Exception { + String collection = params.required().get("collection"); + String coreName = params.get("core"); + String replicaName = params.get("replica"); + if (coreName == null && replicaName == null) { + throw new Exception("Either 'core' or 'replica' must be specified."); + } else if (coreName != null && replicaName != null) { + throw new Exception("Only one of 'core' or 'replica' must be specified."); + } + int waitSec = params.required().getInt("wait", CloudUtil.DEFAULT_TIMEOUT); + String stateStr = params.get("state", Replica.State.ACTIVE.toString()); + Replica.State expectedState = Replica.State.getState(stateStr); + + TimeOut timeout = new TimeOut(waitSec, TimeUnit.SECONDS, script.timeSource); + DocCollection coll = null; + Replica replica = null; + while (!timeout.hasTimedOut()) { + ClusterState clusterState = script.cloudManager.getClusterStateProvider().getClusterState(); + coll = clusterState.getCollectionOrNull(collection); + if (coll == null) { // does not yet exist? + timeout.sleep(250); + continue; + } + for (Replica r : coll.getReplicas()) { + if (coreName != null && coreName.equals(r.getCoreName())) { + replica = r; + break; + } else if (replicaName != null && replicaName.equals(r.getName())) { + replica = r; + break; + } + } + if (replica == null) { + timeout.sleep(250); + continue; + } + if (replica.getState() != expectedState) { + timeout.sleep(250); + continue; + } else { + break; + } + } + if (timeout.hasTimedOut()) { + String msg = "Timed out waiting for replica: collection=" + collection + ", "; + if (coreName != null) { + msg += "core=" + coreName; + } else { + msg += "replica=" + replicaName; + } + if (coll == null) { + msg += ". Collection '" + collection + " not found."; + } else if (replica == null) { + msg += ". Replica not found, last collection state: " + coll; + } else { + msg += ". Replica did not reach desired state, last state: " + replica; + } + throw new Exception(msg); + } + return replica; } } @@ -463,57 +709,63 @@ public class Script implements AutoCloseable { } } } - } - - public static class Assert extends ScriptOp { - @Override - public void execute(Script script) throws Exception { - String key = params.get("key"); - Condition condition = Condition.get(params.required().get("condition")); - if (condition == null) { - throw new IOException("Invalid 'condition' in params: " + params); - } - String expected = params.get("expected"); - if (condition != Condition.NOT_NULL && condition != Condition.NULL && expected == null) { - throw new IOException("'expected' param is required when condition is " + condition); - } - Object value; - if (key != null) { - if (key.contains("/")) { - value = Utils.getObjectByPath(script.context, true, key); - } else { - value = script.context.get(key); - } - } else { - value = params.required().get("value"); - } + public static boolean eval(Condition condition, Object value, String expected) { switch (condition) { case NULL: if (value != null) { - throw new IOException("expected value should be null but was '" + value + "'"); + return false; } break; case NOT_NULL: if (value == null) { - throw new IOException("expected value should not be null"); + return false; } break; case EQUALS: if (!expected.equals(String.valueOf(value))) { - throw new IOException("expected value is '" + expected + "' but actual value is '" + value + "'"); + return false; } break; case NOT_EQUALS: if (expected.equals(String.valueOf(value))) { - throw new IOException("expected value is '" + expected + "' and actual value is the same while it should be different"); + return false; } break; } + return true; } } - public static Script loadResource(SolrResourceLoader loader, CloudSolrClient client, String nodeName, String resource) throws Exception { + public static class AssertOp extends ScriptOp { + + @Override + public Object execute(Script script) throws Exception { + String key = params.get("key"); + Condition condition = Condition.get(params.required().get("condition")); + if (condition == null) { + throw new IOException("Invalid 'condition' in params: " + params); + } + String expected = params.get("expected"); + if (condition != Condition.NOT_NULL && condition != Condition.NULL && expected == null) { + throw new IOException("'expected' param is required when condition is " + condition); + } + Object value; + if (key != null) { + value = Utils.getObjectByPath(script.context, true, key); + } else { + value = params.required().get("value"); + } + if (!Condition.eval(condition, value, expected)) { + throw new Exception("Assertion failed: expected=" + expected + + ", condition=" + condition + + ", value=" + value); + } + return Boolean.TRUE; + } + } + + public static Script parseResource(SolrResourceLoader loader, CloudSolrClient client, String nodeName, String resource) throws Exception { String data; try { InputStream in = loader.openResource(resource); @@ -521,24 +773,37 @@ public class Script implements AutoCloseable { } catch (IOException e) { throw new Exception("cannot open script resource " + resource, e); } - return load(client, nodeName, data); + return parse(client, nodeName, data); } + private static final String PROP_PREFIX = "script."; + /** - * Parse a DSL string and create a scenario ready to run. + * Parse a DSL string and create a script ready to run. * @param client connected Solr client * @param nodeName my node name, if applicable, or null * @param data DSL string with commands and parameters - * @return configured scenario + * @return configured script * @throws Exception on syntax errors */ - public static Script load(CloudSolrClient client, String nodeName, String data) throws Exception { + public static Script parse(CloudSolrClient client, String nodeName, String data) throws Exception { Objects.requireNonNull(client, "Solr client must not be null here"); Objects.requireNonNull(data, "script data must not be null"); @SuppressWarnings("resource") Script script = new Script(client, nodeName); + // process system properties and put them in context + System.getProperties().forEach((name, value) -> { + String key = name.toString(); + if (!key.startsWith(PROP_PREFIX)) { + return; + } + script.context.put(key.substring(PROP_PREFIX.length()), value); + }); + Stack<CompoundOp> compoundOps = new Stack<>(); + Stack<Integer> compoundStarts = new Stack<>(); String[] lines = data.split("\\r?\\n"); for (int i = 0; i < lines.length; i++) { + int lineNum = i + 1; String line = lines[i]; line = line.trim(); if (line.trim().isEmpty() || line.startsWith("#") || line.startsWith("//")) { @@ -550,19 +815,24 @@ public class Script implements AutoCloseable { // split on blank String[] parts = expr.split("\\s+"); if (parts.length > 2) { - log.warn("Invalid line - wrong number of parts {}, skipping: {}", parts.length, line); - continue; + throw new Exception("Syntax error on line " + lineNum + ": invalid line - wrong number of parts " + parts.length + ", expected at most 2."); } ScriptAction action = ScriptAction.get(parts[0]); if (action == null) { - log.warn("Invalid script action {}, skipping...", parts[0]); - continue; + throw new Exception("Syntax error on line " + lineNum + ": invalid script action '" + parts[0] + "'."); } - if (action == ScriptAction.LOOP_END) { - if (!script.context.containsKey("loop")) { - throw new IOException("LOOP_END without start!"); + if (action == ScriptAction.END) { + if (compoundOps.isEmpty()) { + throw new Exception("Syntax error on line " + lineNum + ": compound block END without start."); } - script.context.remove("loop"); + compoundOps.pop(); + compoundStarts.pop(); + continue; + } else if (action == ScriptAction.ELSE) { + if (!(compoundOps.peek() instanceof IfElseOp)) { + throw new Exception("Syntax error on line " + lineNum + ": ELSE without IF start."); + } + ((IfElseOp) compoundOps.peek()).parsingElse = true; continue; } Class<? extends ScriptOp> opClass = supportedOps.get(action); @@ -585,70 +855,94 @@ public class Script implements AutoCloseable { } op.init(params); // loop handling - if (action == ScriptAction.LOOP_START) { - if (script.context.containsKey("loop")) { - throw new IOException("only one loop level is allowed"); - } - script.context.put("loop", op); + if (action == ScriptAction.LOOP || action == ScriptAction.IF) { + compoundOps.add((CompoundOp) op); + compoundStarts.add(lineNum); script.ops.add(op); continue; } - LoopOp currentLoop = (LoopOp) script.context.get("loop"); - if (currentLoop != null) { - currentLoop.ops.add(op); + CompoundOp currentCompound = compoundOps.isEmpty() ? null : compoundOps.peek(); + if (currentCompound != null) { + currentCompound.addOp(op); } else { script.ops.add(op); } } - if (script.context.containsKey("loop")) { - throw new IOException("Unterminated loop statement"); + if (!compoundOps.isEmpty()) { + throw new Exception("Syntax error on EOF: unterminated statement " + + compoundOps.peek() + ". Started on line " + compoundStarts.peek()); } return script; } + /** + * Run the script. This is a one-shot operation + */ public void run() throws Exception { - ExecutorService executor = ExecutorUtil.newMDCAwareSingleThreadExecutor(new SolrNamedThreadFactory("Script")); + if (executorService == null) { + throw new Exception("This script instance cannot be reused."); + } try { - run(executor); + for (int i = 0; i < ops.size(); i++) { + if (abortScript) { + log.info("-- abortScript requested, aborting after {} ops.", i); + return; + } + ScriptOp op = ops.get(i); + if (log.isInfoEnabled()) { + log.info("{}.\t{}\t{}", i + 1, op.getClass().getSimpleName(), op.initParams); // logOk + } + execOp(op); + } + } catch (Exception e) { + throw e; } finally { - executor.shutdownNow(); - executor.awaitTermination(30, TimeUnit.SECONDS); + close(); } } - /** - * Run the script. - */ - public void run(ExecutorService executor) throws Exception { - for (int i = 0; i < ops.size(); i++) { - if (abortScript) { - log.info("-- abortScript requested, aborting after {} ops.", i); - return; + private void execOp(ScriptOp op) throws Exception { + // substitute parameters based on the current context + ClusterStateProvider clusterStateProvider = client.getClusterStateProvider(); + ArrayList<String> liveNodes = new ArrayList<>(clusterStateProvider.getLiveNodes()); + context.put(LIVE_NODES_CTX_PROP, liveNodes); + String randomNode = liveNodes.get(random.nextInt(liveNodes.size())); + context.put(RANDOM_NODE_CTX_PROP, randomNode); + ClusterState clusterState = clusterStateProvider.getClusterState(); + context.put(COLLECTIONS_CTX_PROP, new ArrayList<>(clusterState.getCollectionStates().keySet())); + if (nodeName != null) { + context.put(NODE_NAME_CTX_PROP, nodeName); + } + op.prepareCurrentParams(this); + if (log.isInfoEnabled()) { + log.info("\t\t{}\t{}", op.getClass().getSimpleName(), op.params); + } + final ErrorHandling currentErrorHandling = getErrorHandling(); + OpResult opResult = null; + if (op instanceof LoopOp) { + // execute directly - the loop will submit its ops to the same executor + try { + Object loopRes = op.execute(this); + if (loopRes instanceof Exception) { + throw (Exception) loopRes; + } else { + opResult = new OpResult(op, loopRes); + opResults.add(opResult); + } + } catch (InterruptedException e) { + throw new Exception("Interrupted while executing op " + op); + } catch (Exception e) { + if (currentErrorHandling != ErrorHandling.IGNORE) { + throw new Exception("Error executing op " + op, e); + } else { + opResult = new OpResult(op, e); + opResults.add(opResult); + } } - ScriptOp op = ops.get(i); - if (log.isInfoEnabled()) { - log.info("{}.\t{}\t{}", i + 1, op.getClass().getSimpleName(), op.initParams); // logOk - } - // substitute parameters based on the current context - ClusterStateProvider clusterStateProvider = client.getClusterStateProvider(); - ArrayList<String> liveNodes = new ArrayList<>(clusterStateProvider.getLiveNodes()); - context.put(LIVE_NODES_CTX_PROP, liveNodes); - String randomNode = liveNodes.get(random.nextInt(liveNodes.size())); - context.put(RANDOM_NODE_CTX_PROP, randomNode); - ClusterState clusterState = clusterStateProvider.getClusterState(); - context.put(COLLECTIONS_CTX_PROP, new ArrayList<>(clusterState.getCollectionStates().keySet())); - if (nodeName != null) { - context.put(NODE_NAME_CTX_PROP, nodeName); - } - op.prepareCurrentParams(this); - if (log.isInfoEnabled()) { - log.info("\t\t{}\t{}", op.getClass().getSimpleName(), op.params); - } - final ErrorHandling currentErrorHandling = getErrorHandling(); - Future<Exception> res = executor.submit(() -> { + } else { + Future<Object> res = executorService.submit(() -> { try { - op.execute(this); - return null; + return op.execute(this); } catch (Exception e) { this.abortScript = currentErrorHandling != ErrorHandling.IGNORE ? true : false; return e; @@ -656,9 +950,12 @@ public class Script implements AutoCloseable { }); long timeout = Long.parseLong(String.valueOf(context.getOrDefault(TIMEOUT_PROP, DEFAULT_OP_TIMEOUT_MS))); try { - Exception error = res.get(timeout, TimeUnit.MILLISECONDS); - if (error != null) { - throw error; + Object result = res.get(timeout, TimeUnit.MILLISECONDS); + if (result instanceof Exception) { + throw (Exception) result; + } else { + opResult = new OpResult(op, result); + opResults.add(opResult); } } catch (TimeoutException e) { throw new Exception("Timeout executing op " + op); @@ -666,19 +963,35 @@ public class Script implements AutoCloseable { throw new Exception("Interrupted while executing op " + op); } catch (Exception e) { if (currentErrorHandling != ErrorHandling.IGNORE) { - throw e; + throw new Exception("Error executing op " + op, e); } else { - continue; + opResult = new OpResult(op, e); + opResults.add(opResult); } } } + if (log.isInfoEnabled()) { + log.info("\t\tResult\t{}", opResult.result); + } + context.put(LAST_RESULT_CTX_PROP, opResult); } public ErrorHandling getErrorHandling() { - return ErrorHandling.get(String.valueOf(context.getOrDefault(ERROR_HANDLING_PROP, ErrorHandling.END_SCRIPT))); + return ErrorHandling.get(String.valueOf(context.getOrDefault(ERROR_HANDLING_PROP, ErrorHandling.ABORT))); } @Override - public void close() throws IOException { + public void close() { + if (executorService != null && shouldCloseExecutor) { + executorService.shutdownNow(); + try { + executorService.awaitTermination(60, TimeUnit.SECONDS); + } catch (InterruptedException e) { + // basically ignore, restore interrupted status + Thread.currentThread().interrupt(); + } finally { + executorService = null; + } + } } } diff --git a/solr/core/src/test-files/solr/initScript.txt b/solr/core/src/test-files/solr/initScript.txt new file mode 100644 index 0000000..24f99ba --- /dev/null +++ b/solr/core/src/test-files/solr/initScript.txt @@ -0,0 +1,19 @@ +# simple comment +// another comment + +set key=myNode&value=${_random_node_} +request /admin/collections?action=CREATE&name=ScriptingTest_collection&numShards=2&nrtReplicas=2 +set key=oneCore&value=${_last_result_/result/success[0]/value/core} +wait_collection collection=ScriptingTest_collection&shards=2&replicas=2 +set key=myNode&value=${_random_node_} +request /admin/collections?action=ADDREPLICA&collection=ScriptingTest_collection&shard=shard1&node=${myNode} +set key=myNode&value=${_live_nodes_[0]} +request /admin/collections?action=ADDREPLICA&collection=ScriptingTest_collection&shard=shard1&node=${myNode} +loop iterations=2 + request /admin/collections?action=ADDREPLICA&collection=ScriptingTest_collection&shard=shard2&node=${myNode} + set key=oneCore&value=${_last_result_/result/success[0]/value/core} + wait_replica collection=ScriptingTest_collection&core=${oneCore}&state=ACTIVE + log key=_last_result_/result[0]/key&format=******%20Replica%20name:%20{}%20****** +end +dump + diff --git a/solr/core/src/test-files/solr/initScript2.txt b/solr/core/src/test-files/solr/initScript2.txt new file mode 100644 index 0000000..9f6a9aa --- /dev/null +++ b/solr/core/src/test-files/solr/initScript2.txt @@ -0,0 +1,4 @@ +set key=_error_handling_&value=${testErrorHandling:fatal} +request /admin/collections?action=DELETE&name=_nonexistent_ +request /admin/collections?action=CREATE&name=foobar&numShards=1&nrtReplicas=1 +wait_collection collection=foobar&shards=1&replicas=1 diff --git a/solr/core/src/test-files/solr/shutdownScript.txt b/solr/core/src/test-files/solr/shutdownScript.txt new file mode 100644 index 0000000..ab9bc3b --- /dev/null +++ b/solr/core/src/test-files/solr/shutdownScript.txt @@ -0,0 +1 @@ +request /admin/collections?action=DELETE&name=foobar diff --git a/solr/core/src/test/org/apache/solr/util/scripting/ScriptingTest.java b/solr/core/src/test/org/apache/solr/util/scripting/ScriptingTest.java index a387f3f..161ed1d 100644 --- a/solr/core/src/test/org/apache/solr/util/scripting/ScriptingTest.java +++ b/solr/core/src/test/org/apache/solr/util/scripting/ScriptingTest.java @@ -1,12 +1,18 @@ package org.apache.solr.util.scripting; import org.apache.solr.client.solrj.cloud.SolrCloudManager; +import org.apache.solr.client.solrj.embedded.JettySolrRunner; import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.common.cloud.ClusterState; +import org.apache.solr.common.cloud.DocCollection; import org.apache.solr.core.CoreContainer; +import org.apache.solr.core.SolrResourceLoader; import org.junit.After; import org.junit.BeforeClass; import org.junit.Test; +import java.io.File; + public class ScriptingTest extends SolrCloudTestCase { private static final String COLLECTION = ScriptingTest.class.getSimpleName() + "_collection"; @@ -16,6 +22,8 @@ public class ScriptingTest extends SolrCloudTestCase { @BeforeClass public static void setupCluster() throws Exception { + // either set this, or copy the scripts into each node's solrHome dir + System.setProperty("solr.allow.unsafe.resourceloading", "true"); configureCluster(3) .addConfig("conf", configset("cloud-minimal")) .configure(); @@ -26,38 +34,93 @@ public class ScriptingTest extends SolrCloudTestCase { @After public void cleanup() throws Exception { cluster.deleteAllCollections(); + System.clearProperty("initScript"); + System.clearProperty("shutdownScript"); + System.clearProperty("testErrorHandling"); + System.clearProperty("solr.allow.unsafe.resourceloading"); } String testBasicScript = "# simple comment\n" + "// another comment\n" + " \n" + - "solr_request /admin/collections?action=CREATE&name=" + COLLECTION + "&numShards=2&nrtReplicas=2\n" + + "set key=myNode&value=${_random_node_}\n" + + "request /admin/collections?action=CREATE&name=" + COLLECTION + "&numShards=2&nrtReplicas=2\n" + + // this one is tricky - retrieve object by index because we don't know the key, and + // then it's a map Entry, so retrieve its 'value' because again we don't know the key + "set key=oneCore&value=${_last_result_/result/success[0]/value/core}\n" + "wait_collection collection=" + COLLECTION + "&shards=2&replicas=2\n" + - "ctx_set key=myNode&value=${_random_node_}\n" + - "solr_request /admin/collections?action=ADDREPLICA&collection=" + COLLECTION + "&shard=shard1&node=${myNode}\n" + - "ctx_set key=myNode&value=${_random_node_}\n" + - "solr_request /admin/collections?action=ADDREPLICA&collection=" + COLLECTION + "&shard=shard1&node=${myNode}\n" + - "loop_start iterations=${iterative}\n" + - " solr_request /admin/collections?action=ADDREPLICA&collection=" + COLLECTION + "&shard=shard1&node=${myNode}\n" + - " solr_request /admin/collections?action=ADDREPLICA&collection=" + COLLECTION + "&shard=shard1&node=${myNode}\n" + - "loop_end\n" + + "set key=myNode&value=${_random_node_}\n" + + "request /admin/collections?action=ADDREPLICA&collection=" + COLLECTION + "&shard=shard1&node=${myNode}\n" + + "set key=myNode&value=${_live_nodes_[0]}\n" + + "request /admin/collections?action=ADDREPLICA&collection=" + COLLECTION + "&shard=shard1&node=${myNode}\n" + + "loop iterations=2\n" + + " request /admin/collections?action=ADDREPLICA&collection=" + COLLECTION + "&shard=shard2&node=${myNode}\n" + + " set key=oneCore&value=${_last_result_/result/success[0]/value/core}\n" + + " wait_replica collection=" + COLLECTION + "&core=${oneCore}&state=ACTIVE\n" + + " log key=_last_result_/result[0]/key&format=******%20Replica%20name:%20{}%20******\n" + + "end\n" + "dump\n"; @Test public void testBasicScript() throws Exception { - Script script = Script.load(cluster.getSolrClient(), null, testBasicScript); - // use string value, otherwise PropertiesUtil won't work properly - script.context.put("iterative", "2"); + Script script = Script.parse(cluster.getSolrClient(), null, testBasicScript); script.run(); + DocCollection coll = cloudManager.getClusterStateProvider().getCollection(COLLECTION); + assertEquals("numShards", 2, coll.getSlices().size()); + assertEquals("num replicas shard1", 4, coll.getSlice("shard1").getReplicas().size()); + assertEquals("num replicas shard2", 4, coll.getSlice("shard2").getReplicas().size()); } @Test public void testLoadResource() throws Exception { - + String scriptName = "initScript.txt"; + JettySolrRunner jetty = cluster.getRandomJetty(random()); + String nodeName = jetty.getNodeName(); + SolrResourceLoader loader = jetty.getCoreContainer().getResourceLoader(); + File scriptFile = new File(TEST_PATH().resolve(scriptName).toString()); +// File destFile = new File(jetty.getSolrHome(), scriptName); +// FileUtils.copyFile(scriptFile, destFile); + Script script = Script.parseResource(loader, cluster.getSolrClient(), nodeName, scriptFile.toString()); + script.run(); + DocCollection coll = cloudManager.getClusterStateProvider().getCollection(COLLECTION); + assertEquals("numShards", 2, coll.getSlices().size()); + assertEquals("num replicas shard1", 4, coll.getSlice("shard1").getReplicas().size()); + assertEquals("num replicas shard2", 4, coll.getSlice("shard2").getReplicas().size()); } public void testErrorHandling() throws Exception { + String scriptName = "initScript2.txt"; + File scriptFile = new File(TEST_PATH().resolve(scriptName).toString()); + System.setProperty("initScript", scriptFile.toString()); + JettySolrRunner jetty = cluster.startJettySolrRunner(); + if (!jetty.getCoreContainer().isShutDown()) { + fail("should have failed - error handling set in the script to FATAL by default"); + } + cluster.stopJettySolrRunner(jetty); + // abort the script + System.setProperty("testErrorHandling", "ABORT"); + jetty = cluster.startJettySolrRunner(); + cluster.waitForNode(jetty, 10); + assertFalse("node should be running when error handling is ABORT", jetty.getCoreContainer().isShutDown()); + ClusterState clusterState = cloudManager.getClusterStateProvider().getClusterState(); + assertNull("there should be no 'foobar' collection", clusterState.getCollectionOrNull("foobar")); + cluster.stopJettySolrRunner(jetty); + + // ignore errors and continue + System.setProperty("testErrorHandling", "IGNORE"); + jetty = cluster.startJettySolrRunner(); + cluster.waitForNode(jetty, 10); + assertFalse("node should be running when error handling is IGNORE", jetty.getCoreContainer().isShutDown()); + clusterState = cloudManager.getClusterStateProvider().getClusterState(); + assertNotNull("there should be 'foobar' collection", clusterState.getCollectionOrNull("foobar")); + // test shutdown script + scriptFile = new File(TEST_PATH().resolve("shutdownScript.txt").toString()); + System.setProperty("shutdownScript", scriptFile.toString()); + cluster.stopJettySolrRunner(jetty); + cluster.waitForJettyToStop(jetty); + clusterState = cloudManager.getClusterStateProvider().getClusterState(); + assertNull("there should be no 'foobar' collection", clusterState.getCollectionOrNull("foobar")); } }
