Index: solr/core/src/java/org/apache/solr/cloud/autoscaling/AutoScalingHandler.java IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- solr/core/src/java/org/apache/solr/cloud/autoscaling/AutoScalingHandler.java (revision e4b3df41a9b1829835dd0201207f4bad6646e311) +++ solr/core/src/java/org/apache/solr/cloud/autoscaling/AutoScalingHandler.java (revision ) @@ -21,9 +21,11 @@ import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; import org.apache.solr.api.Api; @@ -31,6 +33,7 @@ import org.apache.solr.common.SolrException; import org.apache.solr.common.cloud.ZkNodeProps; import org.apache.solr.common.cloud.ZkStateReader; +import org.apache.solr.common.util.StrUtils; import org.apache.solr.common.util.Utils; import org.apache.solr.core.CoreContainer; import org.apache.solr.handler.RequestHandlerBase; @@ -88,9 +91,124 @@ break; case "remove-trigger": handleRemoveTrigger(req, rsp, op); + break; + case "set-listener": + handleSetListener(req, rsp, op); + break; + case "remove-listener": + handleRemoveListener(req, rsp, op); + break; } } } + + private void handleRemoveListener(SolrQueryRequest req, SolrQueryResponse rsp, CommandOperation op) throws KeeperException, InterruptedException { + String listenerName = op.getStr("name"); + + if (listenerName == null || listenerName.trim().length() == 0) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "The listener name cannot be null or empty"); + } + Map autoScalingConf = zkReadAutoScalingConf(container.getZkController().getZkStateReader()); + Map listeners = (Map) autoScalingConf.get("listeners"); + if (listeners == null || !listeners.containsKey(listenerName)) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No listener exists with name: " + listenerName); + } + zkSetListener(container.getZkController().getZkStateReader(), listenerName, null); + rsp.getValues().add("result", "success"); + } + + private void handleSetListener(SolrQueryRequest req, SolrQueryResponse rsp, CommandOperation op) throws KeeperException, InterruptedException { + String listenerName = op.getStr("name"); + String triggerName = op.getStr("trigger"); + String stageName = op.getStr("stage", null); + String listenerClass = op.getStr("class"); + String beforeActions = op.getStr("beforeAction", null); + String afterActions = op.getStr("afterAction", null); + + if (listenerName == null || listenerName.trim().length() == 0) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "The listener name cannot be null or empty"); + } + + Map autoScalingConf = zkReadAutoScalingConf(container.getZkController().getZkStateReader()); + Map triggers = (Map) autoScalingConf.get("triggers"); + if (triggers == null || !triggers.containsKey(triggerName)) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "A trigger with the name " + triggerName + " does not exist"); + } + Map triggerProps = (Map) triggers.get(triggerName); + + if (stageName == null || stageName.trim().length() == 0) { + if (beforeActions == null || beforeActions.trim().length() == 0) { + if (afterActions == null || afterActions.trim().length() == 0) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Either 'stage' or 'beforeAction' or 'afterAction' must be specified"); + } + } + } + + if (stageName != null) { + List stages = StrUtils.splitSmart(stageName, ','); + for (String stage : stages) { + try { + AutoScaling.TriggerStage.valueOf(stage); + } catch (IllegalArgumentException e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Invalid stage name: " + stage); + } + } + } + + if (listenerClass == null || listenerClass.trim().length() == 0) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "The 'class' of the listener cannot be null or empty"); + } + // validate that we can load the listener class + // todo nocommit -- what about MemClassLoader? + try { + container.getResourceLoader().findClass(listenerClass, AutoScaling.TriggerListener.class); + } catch (Exception e) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Listener not found: " + listenerClass, e); + } + + List> actions = (List>) triggerProps.get("actions"); + Set actionNames = new HashSet<>(); + if (beforeActions != null) { + actionNames.addAll(StrUtils.splitSmart(beforeActions, ',')); + } + if (afterActions != null) { + actionNames.addAll(StrUtils.splitSmart(afterActions, ',')); + } + for (Map action : actions) { + actionNames.remove(action.get("name")); + } + if (!actionNames.isEmpty()) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "The trigger '" + triggerName + "' does not have actions named: " + actionNames); + } + + // todo - handle races between competing set-trigger and set-listener invocations + zkSetListener(container.getZkController().getZkStateReader(), listenerName, op.getValuesExcluding("name")); + rsp.getValues().add("result", "success"); + } + + private void zkSetListener(ZkStateReader reader, String listenerName, Map listenerProperties) throws KeeperException, InterruptedException { + while (true) { + Stat stat = new Stat(); + ZkNodeProps loaded = null; + byte[] data = reader.getZkClient().getData(SOLR_AUTOSCALING_CONF_PATH, null, stat, true); + loaded = ZkNodeProps.load(data); + Map listeners = (Map) loaded.get("listeners"); + if (listeners == null) listeners = new HashMap<>(1); + if (listenerProperties != null) { + listeners.put(listenerName, listenerProperties); + } else { + listeners.remove(listenerName); + } + loaded = loaded.plus("listeners", listeners); + try { + reader.getZkClient().setData(SOLR_AUTOSCALING_CONF_PATH, Utils.toJSON(loaded), stat.getVersion(), true); + } catch (KeeperException.BadVersionException bve) { + // somebody else has changed the configuration so we must retry + continue; + } + break; + } + } private void handleSetTrigger(SolrQueryRequest req, SolrQueryResponse rsp, CommandOperation op) throws KeeperException, InterruptedException { String triggerName = op.getStr("name"); @@ -144,7 +262,7 @@ try { container.getResourceLoader().findClass(klass, TriggerAction.class); } catch (Exception e) { - throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Error trying to find Action: " + klass, e); + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Action not found: " + klass, e); } } @@ -154,6 +272,7 @@ private void handleRemoveTrigger(SolrQueryRequest req, SolrQueryResponse rsp, CommandOperation op) throws KeeperException, InterruptedException { String triggerName = op.getStr("name"); + boolean removeListeners = op.getBoolean("removeListeners", false); if (triggerName == null || triggerName.trim().length() == 0) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "The trigger name cannot be null or empty"); @@ -163,6 +282,26 @@ if (triggers == null || !triggers.containsKey(triggerName)) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No trigger exists with name: " + triggerName); } + + Map> listeners = (Map>) autoScalingConf.get("listeners"); + Set activeListeners = new HashSet<>(); + if (listeners != null) { + for (Map.Entry> entry : listeners.entrySet()) { + Map listenerProps = entry.getValue(); + if (triggerName.equals(listenerProps.get("trigger")) && !removeListeners) { + activeListeners.add(entry.getKey()); + } + } + } + if (removeListeners) { + for (String activeListener : activeListeners) { + zkSetListener(container.getZkController().getZkStateReader(), activeListener, null); + } + } else if (!activeListeners.isEmpty()) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, + "No listeners should exist for trigger: " + triggerName + ". Found listeners: " + activeListeners); + } + zkSetTrigger(container.getZkController().getZkStateReader(), triggerName, null); rsp.getValues().add("result", "success"); } Index: solr/core/src/resources/apispec/autoscaling.Commands.json IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- solr/core/src/resources/apispec/autoscaling.Commands.json (revision e4b3df41a9b1829835dd0201207f4bad6646e311) +++ solr/core/src/resources/apispec/autoscaling.Commands.json (revision ) @@ -11,36 +11,36 @@ }, "commands": { "set-trigger": { - "type":"object", + "type": "object", "description": "The set-trigger command allows you to add and update triggers on various system metrics", "properties": { "name": { "type": "string", "description": "The name of the trigger" }, - "event" : { - "type" : "string", - "description" : "The event type on which to set a trigger" + "event": { + "type": "string", + "description": "The event type on which to set a trigger" }, - "waitFor" : { - "type" : "string", - "description" : "The amount of time to wait after the trigger condition is satisfied before trigger is activated" + "waitFor": { + "type": "string", + "description": "The amount of time to wait after the trigger condition is satisfied before trigger is activated" }, - "lowerBound" : { - "type" : "number", - "description" : "The lower bound of the condition below which the trigger is activated" + "lowerBound": { + "type": "number", + "description": "The lower bound of the condition below which the trigger is activated" }, - "upperBound" : { - "type" : "number", - "description" : "The upper bound of the condition below which the trigger is activated" + "upperBound": { + "type": "number", + "description": "The upper bound of the condition below which the trigger is activated" }, - "enabled" : { - "type" : "boolean", - "description" : "The state of the trigger" + "enabled": { + "type": "boolean", + "description": "The state of the trigger" }, - "actions" : { - "type" : "array", - "description" : "The actions to be performed in sequence when the trigger is activated", + "actions": { + "type": "array", + "description": "The actions to be performed in sequence when the trigger is activated", "items": { "type": "object" } @@ -53,13 +53,69 @@ }, "remove-trigger": { "description": "Remove a trigger", - "type":"object", - "properties" : { - "name" : { - "type" : "string", - "description" : "The name of the trigger to be removed" + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the trigger to be removed" + }, + "removeListeners": { + "type": "boolean", + "description": "If true, all listeners of this triggers are deleted together with the trigger" } - } + }, + "required": [ + "name" + ] + }, + "set-listener": { + "description": "The set-listener command lets you add a listener to a trigger", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the listener" + }, + "trigger": { + "type": "string", + "description": "The name of the trigger to listen to" + }, + "stage": { + "type": "string", + "description": "The stage of the trigger for which to listen" + }, + "beforeAction": { + "type": "string", + "description": "The name of the action before which the listener should be notified" + }, + "afterAction": { + "type": "string", + "description": "The name of the action after which the listener should be notified" + }, + "class": { + "type": "string", + "description": "The listener class to be notified once the given stage of the given trigger is activated" + } + }, + "required": [ + "name", + "trigger", + "class" + ], + "additionalProperties": true + }, + "remove-listener": { + "description": "Remove a listener", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the listener to be removed" + } + }, + "required": [ + "name" + ] } } } Index: solr/core/src/java/org/apache/solr/cloud/autoscaling/AutoScaling.java IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- solr/core/src/java/org/apache/solr/cloud/autoscaling/AutoScaling.java (revision e4b3df41a9b1829835dd0201207f4bad6646e311) +++ solr/core/src/java/org/apache/solr/cloud/autoscaling/AutoScaling.java (revision ) @@ -17,6 +17,11 @@ package org.apache.solr.cloud.autoscaling; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; + public class AutoScaling { public enum EventType { @@ -29,4 +34,42 @@ INDEXRATE } + public enum TriggerStage { + STARTED, + ABORTED, + SUCCEEDED, + FAILED, + BEFORE_ACTION, + AFTER_ACTION + } + + public static interface TriggerListener { + public void triggerFired(Trigger trigger, Event event); + } + + public static class HttpCallbackListener implements TriggerListener { + @Override + public void triggerFired(Trigger trigger, Event event) { + + } + } + + public static interface Trigger { + public String getName(); + + public EventType getEventType(); + + public boolean isEnabled(); + + public Map getProperties(); + } + + public static interface Event { + public String getSource(); + + public Date getTime(); + + public EventType getType(); + } + } Index: solr/core/src/test/org/apache/solr/cloud/autoscaling/AutoScalingHandlerTest.java IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== --- solr/core/src/test/org/apache/solr/cloud/autoscaling/AutoScalingHandlerTest.java (revision e4b3df41a9b1829835dd0201207f4bad6646e311) +++ solr/core/src/test/org/apache/solr/cloud/autoscaling/AutoScalingHandlerTest.java (revision ) @@ -26,6 +26,7 @@ import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrResponse; import org.apache.solr.client.solrj.impl.CloudSolrClient; +import org.apache.solr.client.solrj.impl.HttpSolrClient; import org.apache.solr.cloud.SolrCloudTestCase; import org.apache.solr.common.cloud.ZkNodeProps; import org.apache.solr.common.cloud.ZkStateReader; @@ -53,7 +54,7 @@ // todo nocommit -- add testing for the v2 path // String path = random().nextBoolean() ? "/admin/autoscaling" : "/v2/cluster/autoscaling"; String path = "/admin/autoscaling"; - String addTriggerCommand = "{\n" + + String setTriggerCommand = "{\n" + "\t\"set-trigger\" : {\n" + "\t\t\"name\" : \"node_lost_trigger\",\n" + "\t\t\"event\" : \"nodeLost\",\n" + @@ -76,7 +77,7 @@ "\t\t]\n" + "\t}\n" + "}"; - SolrRequest req = new AutoScalingRequest(SolrRequest.METHOD.POST, path, addTriggerCommand); + SolrRequest req = new AutoScalingRequest(SolrRequest.METHOD.POST, path, setTriggerCommand); NamedList response = solrClient.request(req); assertEquals(response.get("result").toString(), "success"); @@ -91,12 +92,100 @@ assertEquals(4, nodeLostTrigger.size()); assertEquals("600", nodeLostTrigger.get("waitFor").toString()); + setTriggerCommand = "{\n" + + "\t\"set-trigger\" : {\n" + + "\t\t\"name\" : \"node_lost_trigger\",\n" + + "\t\t\"event\" : \"nodeLost\",\n" + + "\t\t\"waitFor\" : \"20m\",\n" + + "\t\t\"enabled\" : \"false\",\n" + + "\t\t\"actions\" : [\n" + + "\t\t\t{\n" + + "\t\t\t\t\"name\" : \"compute_plan\",\n" + + "\t\t\t\t\"class\" : \"solr.ComputePlanAction\"\n" + + "\t\t\t},\n" + + "\t\t\t{\n" + + "\t\t\t\t\"name\" : \"execute_plan\",\n" + + "\t\t\t\t\"class\" : \"solr.ExecutePlanAction\"\n" + + "\t\t\t},\n" + + "\t\t\t{\n" + + "\t\t\t\t\"name\" : \"log_plan\",\n" + + "\t\t\t\t\"class\" : \"solr.LogPlanAction\",\n" + + "\t\t\t\t\"collection\" : \".system\"\n" + + "\t\t\t}\n" + + "\t\t]\n" + + "\t}\n" + + "}"; + req = new AutoScalingRequest(SolrRequest.METHOD.POST, path, setTriggerCommand); + response = solrClient.request(req); + assertEquals(response.get("result").toString(), "success"); + data = zkClient().getData(ZkStateReader.SOLR_AUTOSCALING_CONF_PATH, null, null, true); + loaded = ZkNodeProps.load(data); + triggers = (Map) loaded.get("triggers"); + assertNotNull(triggers); + assertEquals(1, triggers.size()); + assertTrue(triggers.containsKey("node_lost_trigger")); + nodeLostTrigger = (Map) triggers.get("node_lost_trigger"); + assertEquals(4, nodeLostTrigger.size()); + assertEquals("1200", nodeLostTrigger.get("waitFor").toString()); + assertEquals("false", nodeLostTrigger.get("enabled").toString()); + + String setListenerCommand = "{\n" + + "\t\"set-listener\" : \n" + + "\t\t{\n" + + "\t\t\t\"name\" : \"xyz\",\n" + + "\t\t\t\"trigger\" : \"node_lost_trigger\",\n" + + "\t\t\t\"stage\" : \"STARTED,ABORTED,SUCCEEDED\",\n" + + "\t\t\t\"beforeAction\" : \"execute_plan\",\n" + + "\t\t\t\"class\" : \"org.apache.solr.cloud.autoscaling.AutoScaling$HttpCallbackListener\",\n" + + "\t\t\t\"url\" : \"http://xyz.com/on_node_lost?node={$LOST_NODE_NAME}\"\n" + + "\t\t}\n" + + "}"; + req = new AutoScalingRequest(SolrRequest.METHOD.POST, path, setListenerCommand); + response = solrClient.request(req); + assertEquals(response.get("result").toString(), "success"); + data = zkClient().getData(ZkStateReader.SOLR_AUTOSCALING_CONF_PATH, null, null, true); + loaded = ZkNodeProps.load(data); + Map listeners = (Map) loaded.get("listeners"); + assertNotNull(listeners); + assertEquals(1, listeners.size()); + assertTrue(listeners.containsKey("xyz")); + Map xyzListener = (Map) listeners.get("xyz"); + assertEquals(5, xyzListener.size()); + assertEquals("org.apache.solr.cloud.autoscaling.AutoScaling$HttpCallbackListener", xyzListener.get("class").toString()); + String removeTriggerCommand = "{\n" + "\t\"remove-trigger\" : {\n" + "\t\t\"name\" : \"node_lost_trigger\"\n" + "\t}\n" + "}"; req = new AutoScalingRequest(SolrRequest.METHOD.POST, path, removeTriggerCommand); + try { + response = solrClient.request(req); + fail("Trying to remove trigger which has listeners registered should have failed"); + } catch (HttpSolrClient.RemoteSolrException e) { + // expected + } + + String removeListenerCommand = "{\n" + + "\t\"remove-listener\" : {\n" + + "\t\t\"name\" : \"xyz\"\n" + + "\t}\n" + + "}"; + req = new AutoScalingRequest(SolrRequest.METHOD.POST, path, removeListenerCommand); + response = solrClient.request(req); + assertEquals(response.get("result").toString(), "success"); + data = zkClient().getData(ZkStateReader.SOLR_AUTOSCALING_CONF_PATH, null, null, true); + loaded = ZkNodeProps.load(data); + listeners = (Map) loaded.get("listeners"); + assertNotNull(listeners); + assertEquals(0, listeners.size()); + + removeTriggerCommand = "{\n" + + "\t\"remove-trigger\" : {\n" + + "\t\t\"name\" : \"node_lost_trigger\"\n" + + "\t}\n" + + "}"; + req = new AutoScalingRequest(SolrRequest.METHOD.POST, path, removeTriggerCommand); response = solrClient.request(req); assertEquals(response.get("result").toString(), "success"); data = zkClient().getData(ZkStateReader.SOLR_AUTOSCALING_CONF_PATH, null, null, true); @@ -104,6 +193,25 @@ triggers = (Map) loaded.get("triggers"); assertNotNull(triggers); assertEquals(0, triggers.size()); + + setListenerCommand = "{\n" + + "\t\"set-listener\" : \n" + + "\t\t{\n" + + "\t\t\t\"name\" : \"xyz\",\n" + + "\t\t\t\"trigger\" : \"node_lost_trigger\",\n" + + "\t\t\t\"stage\" : \"STARTED,ABORTED,SUCCEEDED\",\n" + +// "\t\t\t\"beforeAction\" : \"execute_plan\",\n" + + "\t\t\t\"class\" : \"org.apache.solr.cloud.autoscaling.AutoScaling$HttpCallbackListener\",\n" + + "\t\t\t\"url\" : \"http://xyz.com/on_node_lost?node={$LOST_NODE_NAME}\"\n" + + "\t\t}\n" + + "}"; + req = new AutoScalingRequest(SolrRequest.METHOD.POST, path, removeListenerCommand); + try { + response = solrClient.request(req); + fail("Adding a listener on a non-existent trigger should have failed"); + } catch (HttpSolrClient.RemoteSolrException e) { + // expected + } } static class AutoScalingRequest extends SolrRequest {